fix(chat): keep the outgoing conversation still until the next one replaces it

Switching sessions moved the conversation on screen before the swap: the
composer and the status chip followed the live selection and re-shaped a
commit ahead of the timeline, so the pinned outgoing chat jumped; and the
reveal effect re-ran for the outgoing session when its waited flag flipped,
hiding it a few frames before the next one mounted. The chat column now
reads one deferred session, and the reveal runs once per opened session.
This commit is contained in:
Bohdan Triapitsyn
2026-08-30 16:54:41 +03:00
parent 02b1ee637d
commit 654b3d2441
5 changed files with 61 additions and 6 deletions
@@ -4,6 +4,7 @@ import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import { ChatInput } from './ChatInput';
import { ChatColumnSessionContext, type ChatColumnSession } from './chatColumnSession';
import { DraftPresetChips } from './DraftPresetChips';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
@@ -391,6 +392,13 @@ const ChatViewport = React.memo(({
const timelineRootRef = React.useRef<HTMLDivElement | null>(null);
const endPinningReleasedRef = React.useRef(endPinningReleased);
endPinningReleasedRef.current = endPinningReleased;
// Read through a ref: the effect runs once per gate (per opened session).
// `revealWaited` flips for the session still on screen the moment another
// one is selected — before the deferred swap mounts it — and re-running
// the effect then would hide the outgoing timeline for the frames until
// the new one arrives.
const revealWaitedRef = React.useRef(revealWaited);
revealWaitedRef.current = revealWaited;
React.useLayoutEffect(() => {
const root = timelineRootRef.current;
if (!root) return;
@@ -440,7 +448,7 @@ const ChatViewport = React.memo(({
if (finished) return;
revealGate.close();
if (revealGate.holds === 0) {
reveal(revealWaited);
reveal(revealWaitedRef.current);
return;
}
revealGate.onEmpty = () => reveal(true);
@@ -452,7 +460,7 @@ const ChatViewport = React.memo(({
if (frame !== null) window.cancelAnimationFrame(frame);
revealGate.onEmpty = null;
};
}, [revealGate, revealWaited, scrollRef]);
}, [revealGate, scrollRef]);
const scrollContainerProps = React.useMemo(() => ({
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
@@ -741,6 +749,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
// viewport is pinned to the end so the first visible frame is already
// at the bottom.
const revealGate = React.useMemo(() => createTimelineRevealGate(), [currentSessionKey]);
const chatColumnSession = React.useMemo<ChatColumnSession>(
() => ({ sessionId: currentSessionId ?? null, directory: currentSessionId ? effectiveSessionDirectory ?? null : null }),
[currentSessionId, effectiveSessionDirectory],
);
const ensureSessionRenderable = React.useCallback(
(sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
[effectiveSessionDirectory, sync],
@@ -1567,6 +1579,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return (
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<ChatColumnSessionContext.Provider value={chatColumnSession}>
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton}
{sessionSurface}
@@ -1651,6 +1664,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
onLoadEarlier={handleLoadOlderClick}
/>
</div>
</ChatColumnSessionContext.Provider>
{/* Kept mounted while it could ever show, so it can animate its own
collapse; `visible` drives that. Unmounting on the spot is what made
the chat jump wide before easing narrow again. */}
+10 -2
View File
@@ -51,6 +51,7 @@ import { ModelControls } from './ModelControls';
import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { ComposerStatusBar } from './ComposerStatusBar';
import { PendingChangesBar } from './PendingChangesBar';
import { useChatColumnSession } from './chatColumnSession';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton';
@@ -335,9 +336,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const sendMessage = React.useRef((...args: any[]) =>
Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)),
).current;
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
// Inside the chat column the composer follows the session the timeline is
// showing (see chatColumnSession.ts); elsewhere it follows the live one.
const liveSessionId = useSessionUIStore((s) => s.currentSessionId);
const chatColumnSession = useChatColumnSession();
const currentSessionId = chatColumnSession ? chatColumnSession.sessionId : liveSessionId;
const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
const currentDirectory = useEffectiveDirectory() ?? fallbackDirectory;
const liveEffectiveDirectory = useEffectiveDirectory();
const currentDirectory = (chatColumnSession?.sessionId ? chatColumnSession.directory : null)
?? liveEffectiveDirectory
?? fallbackDirectory;
const currentSessionDirectoryForSync = useSessionUIStore(
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
);
@@ -0,0 +1,18 @@
import React from 'react';
/**
* The session the chat column is showing — the deferred selection the
* timeline renders, not the live store value. The composer and everything
* stacked with the timeline read it so the column changes as one: a session
* click publishes the live selection first, and a composer that followed it
* would change height (changed-files row, todos, queued chips) while the
* outgoing timeline is still on screen, shoving that timeline before the swap.
*/
export type ChatColumnSession = {
sessionId: string | null;
directory: string | null;
};
export const ChatColumnSessionContext = React.createContext<ChatColumnSession | null>(null);
export const useChatColumnSession = (): ChatColumnSession | null => React.useContext(ChatColumnSessionContext);