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 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. */}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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 ?? '',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user