diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 25427806..eff5f119 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -160,7 +160,6 @@ type ChatViewportProps = { onAnchorSizeChanged: (messageId: string) => void; onIsAtEndChange: (isAtEnd: boolean) => void; onTimelineDataChange: () => void; - pendingRevealWork: boolean; renderedMessages: SessionMessageRecord[]; isLoadingOlder: boolean; sessionIsWorking: boolean; @@ -174,6 +173,9 @@ type ChatViewportProps = { } | null; scrollToBottom: () => void; endPinningReleased: boolean; + // One-shot fade for content that replaced the hydration skeleton; + // cached sessions render instantly without it. + revealContent: boolean; sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; isProgrammaticFollowActive: boolean; @@ -202,7 +204,6 @@ const ChatViewport = React.memo(({ onAnchorSizeChanged, onIsAtEndChange, onTimelineDataChange, - pendingRevealWork, renderedMessages, isLoadingOlder, sessionIsWorking, @@ -211,6 +212,7 @@ const ChatViewport = React.memo(({ retryOverlay, scrollToBottom, endPinningReleased, + revealContent, sessionQuestions, sessionPermissions, isProgrammaticFollowActive, @@ -377,7 +379,8 @@ const ChatViewport = React.memo(({ 'relative min-h-0', isDesktopExpandedInput ? 'absolute inset-0 opacity-0 pointer-events-none' - : 'flex-1' + : 'flex-1', + revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal', )} aria-hidden={isDesktopExpandedInput} > @@ -386,7 +389,6 @@ const ChatViewport = React.memo(({ key={currentSessionKey} ref={messageListRef} sessionKey={currentSessionId} - disableStaging={pendingRevealWork} messages={renderedMessages} sessionIsWorking={sessionIsWorking} activeStreamingMessageId={streamingMessageId} @@ -433,7 +435,6 @@ const ChatViewport = React.memo(({ && prev.directory === next.directory && prev.scrollRef === next.scrollRef && prev.messageListRef === next.messageListRef - && prev.pendingRevealWork === next.pendingRevealWork && prev.renderedMessages === next.renderedMessages && prev.isLoadingOlder === next.isLoadingOlder && prev.sessionIsWorking === next.sessionIsWorking @@ -442,6 +443,7 @@ const ChatViewport = React.memo(({ && prev.retryOverlay === next.retryOverlay && prev.scrollToBottom === next.scrollToBottom && prev.endPinningReleased === next.endPinningReleased + && prev.revealContent === next.revealContent && prev.sessionQuestions === next.sessionQuestions && prev.sessionPermissions === next.sessionPermissions && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive @@ -815,6 +817,10 @@ export const ChatContainer: React.FC = ({ return () => setWorkStatusPanelVisible(false); }, [setWorkStatusPanelVisible, showWorkStatusPanel]); const messageListRef = React.useRef(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(null); + const currentSession = useSession(currentSessionId, effectiveSessionDirectory); const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory); @@ -1150,6 +1156,15 @@ export const ChatContainer: React.FC = ({ const isSessionHydrating = Boolean(currentSessionId) && !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(() => { if (!messagesEnabled || !currentSessionId) return; void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory); @@ -1270,7 +1285,11 @@ export const ChatContainer: React.FC = ({ return ; } - 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') { return (
@@ -1290,6 +1309,7 @@ export const ChatContainer: React.FC = ({ return (
= ({ onIsAtEndChange={onIsAtEndChange} onTimelineDataChange={onTimelineDataChange} messageListRef={messageListRef} - pendingRevealWork={timelineController.pendingRevealWork} renderedMessages={timelineController.renderedMessages} isLoadingOlder={timelineController.isLoadingOlder} sessionIsWorking={sessionIsWorking} @@ -1362,6 +1381,7 @@ export const ChatContainer: React.FC = ({ retryOverlay={retryOverlay} scrollToBottom={resumeToLatestInstant} endPinningReleased={userOwnsScroll} + revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)} sessionQuestions={sessionQuestions} sessionPermissions={sessionPermissions} isProgrammaticFollowActive={isFollowingProgrammatically} diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index b8af3e03..35667011 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -345,6 +345,13 @@ const useFileReferenceInteractions = ({ if (!container) { 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; const fileReferenceLinkLimit = getFileReferenceLinkLimit(); // On mobile surfaces, file-reference highlighting is disabled entirely — not @@ -398,6 +405,19 @@ const useFileReferenceInteractions = ({ }; 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) { wrapBlockCodePathTokens(container); } @@ -526,7 +546,12 @@ const useFileReferenceInteractions = ({ 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(() => { + if (annotationWriteDepth > 0) return; scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS); }); observer.observe(container, { diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index b756d439..94591ad8 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -305,7 +305,6 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD interface MessageListProps { sessionKey: string; - disableStaging?: boolean; messages: ChatMessageEntry[]; sessionIsWorking?: boolean; activeStreamingMessageId?: string | null; diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts index 6dba0f2b..65d5b6ed 100644 --- a/packages/ui/src/components/chat/markdown/decorate.ts +++ b/packages/ui/src/components/chat/markdown/decorate.ts @@ -131,6 +131,9 @@ const layoutCodeLines = (pre: HTMLPreElement): void => { const code = pre.querySelector(':scope > code'); 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 hasTrailingNewline = text.endsWith('\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.background = 'transparent'; 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); wrapper.appendChild(header); wrapper.appendChild(body); diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index ed6c021f..1ba7e73c 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1356,6 +1356,36 @@ html:not(.dark) .chat-scroll { 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] { align-self: stretch; padding-right: 0.75rem;