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 type { QuestionRequest } from '@/types/question';
import { ChatInput } from './ChatInput'; import { ChatInput } from './ChatInput';
import { ChatColumnSessionContext, type ChatColumnSession } from './chatColumnSession';
import { DraftPresetChips } from './DraftPresetChips'; import { DraftPresetChips } from './DraftPresetChips';
import { useInputStore } from '@/sync/input-store'; import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
@@ -391,6 +392,13 @@ const ChatViewport = React.memo(({
const timelineRootRef = React.useRef<HTMLDivElement | null>(null); const timelineRootRef = React.useRef<HTMLDivElement | null>(null);
const endPinningReleasedRef = React.useRef(endPinningReleased); const endPinningReleasedRef = React.useRef(endPinningReleased);
endPinningReleasedRef.current = 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(() => { React.useLayoutEffect(() => {
const root = timelineRootRef.current; const root = timelineRootRef.current;
if (!root) return; if (!root) return;
@@ -440,7 +448,7 @@ const ChatViewport = React.memo(({
if (finished) return; if (finished) return;
revealGate.close(); revealGate.close();
if (revealGate.holds === 0) { if (revealGate.holds === 0) {
reveal(revealWaited); reveal(revealWaitedRef.current);
return; return;
} }
revealGate.onEmpty = () => reveal(true); revealGate.onEmpty = () => reveal(true);
@@ -452,7 +460,7 @@ const ChatViewport = React.memo(({
if (frame !== null) window.cancelAnimationFrame(frame); if (frame !== null) window.cancelAnimationFrame(frame);
revealGate.onEmpty = null; revealGate.onEmpty = null;
}; };
}, [revealGate, revealWaited, scrollRef]); }, [revealGate, scrollRef]);
const scrollContainerProps = React.useMemo(() => ({ const scrollContainerProps = React.useMemo(() => ({
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target', 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 // viewport is pinned to the end so the first visible frame is already
// at the bottom. // at the bottom.
const revealGate = React.useMemo(() => createTimelineRevealGate(), [currentSessionKey]); 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( const ensureSessionRenderable = React.useCallback(
(sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory), (sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
[effectiveSessionDirectory, sync], [effectiveSessionDirectory, sync],
@@ -1567,6 +1579,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return ( return (
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background"> <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"> <div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton} {returnToParentButton}
{sessionSurface} {sessionSurface}
@@ -1651,6 +1664,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
onLoadEarlier={handleLoadOlderClick} onLoadEarlier={handleLoadOlderClick}
/> />
</div> </div>
</ChatColumnSessionContext.Provider>
{/* Kept mounted while it could ever show, so it can animate its own {/* Kept mounted while it could ever show, so it can animate its own
collapse; `visible` drives that. Unmounting on the spot is what made collapse; `visible` drives that. Unmounting on the spot is what made
the chat jump wide before easing narrow again. */} 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 { parseAgentMentions } from '@/lib/messages/agentMentions';
import { ComposerStatusBar } from './ComposerStatusBar'; import { ComposerStatusBar } from './ComposerStatusBar';
import { PendingChangesBar } from './PendingChangesBar'; import { PendingChangesBar } from './PendingChangesBar';
import { useChatColumnSession } from './chatColumnSession';
import { useChatSurfaceMode } from './useChatSurfaceMode'; import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton'; import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton'; import { MobileModelButton } from './MobileModelButton';
@@ -335,9 +336,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const sendMessage = React.useRef((...args: any[]) => const sendMessage = React.useRef((...args: any[]) =>
Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)), Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)),
).current; ).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 fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
const currentDirectory = useEffectiveDirectory() ?? fallbackDirectory; const liveEffectiveDirectory = useEffectiveDirectory();
const currentDirectory = (chatColumnSession?.sessionId ? chatColumnSession.directory : null)
?? liveEffectiveDirectory
?? fallbackDirectory;
const currentSessionDirectoryForSync = useSessionUIStore( const currentSessionDirectoryForSync = useSessionUIStore(
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]), 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);
+9 -2
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { useChatColumnSession } from '@/components/chat/chatColumnSession';
import type { Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2'; import type { Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2';
import type { MessageStreamPhase } from '@/stores/types/sessionTypes'; import type { MessageStreamPhase } from '@/stores/types/sessionTypes';
@@ -301,8 +302,14 @@ export const getActiveAssistantContext = (messages: Message[]): ActiveAssistantC
}; };
export function useAssistantStatus(): AssistantStatusSnapshot { export function useAssistantStatus(): AssistantStatusSnapshot {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); // Inside the chat column, follow the session the timeline shows rather
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); // than the live selection, so the status chip changes together with the
// conversation instead of a commit ahead of it.
const chatColumnSession = useChatColumnSession();
const liveSessionId = useSessionUIStore((state) => state.currentSessionId);
const liveSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const currentSessionId = chatColumnSession ? chatColumnSession.sessionId : liveSessionId;
const currentSessionDirectory = chatColumnSession ? chatColumnSession.directory : liveSessionDirectory;
const rawSessionMessages = useSessionMessages( const rawSessionMessages = useSessionMessages(
currentSessionId ?? '', currentSessionId ?? '',
+8
View File
@@ -464,6 +464,14 @@ the user waited for fades in (100ms); one that was ready appears in the same
frame. The sidebar prefetches the two rows on either side of the open session frame. The sidebar prefetches the two rows on either side of the open session
shortly after it settles, so most neighbouring switches are warm. shortly after it settles, so most neighbouring switches are warm.
The column changes as one. The composer and the status chip above it read
the session the timeline shows (`components/chat/chatColumnSession.ts`), not
the live selection: read live, they re-shaped a commit ahead of the swap
(a taller draft, chips, a working chip) and the outgoing timeline, pinned to
its end, jumped before it was replaced. The reveal effect below runs once per
gate for the same reason — `revealWaited` flips for the outgoing session at
the click, and re-running on it hid that timeline before the next one mounted.
The timeline's first paint for a session is atomic. `ChatContainer` owns a The timeline's first paint for a session is atomic. `ChatContainer` owns a
`TimelineRevealGate` per session key (`components/chat/timelineRevealGate.ts`): `TimelineRevealGate` per session key (`components/chat/timelineRevealGate.ts`):
a markdown renderer whose first paint is provisional (blocks not yet in the a markdown renderer whose first paint is provisional (blocks not yet in the