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:
@@ -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. */}
|
||||
|
||||
@@ -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);
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useChatColumnSession } from '@/components/chat/chatColumnSession';
|
||||
import type { Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import type { MessageStreamPhase } from '@/stores/types/sessionTypes';
|
||||
@@ -301,8 +302,14 @@ export const getActiveAssistantContext = (messages: Message[]): ActiveAssistantC
|
||||
};
|
||||
|
||||
export function useAssistantStatus(): AssistantStatusSnapshot {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
// Inside the chat column, follow the session the timeline shows rather
|
||||
// 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(
|
||||
currentSessionId ?? '',
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`TimelineRevealGate` per session key (`components/chat/timelineRevealGate.ts`):
|
||||
a markdown renderer whose first paint is provisional (blocks not yet in the
|
||||
|
||||
Reference in New Issue
Block a user