diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 5c70ee79..295a5411 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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(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 = ({ // 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( + () => ({ 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 = ({ return (
+
{returnToParentButton} {sessionSurface} @@ -1651,6 +1664,7 @@ export const ChatContainer: React.FC = ({ onLoadEarlier={handleLoadOlderClick} />
+
{/* 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. */} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 4220e54e..371861f0 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ 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]), ); diff --git a/packages/ui/src/components/chat/chatColumnSession.ts b/packages/ui/src/components/chat/chatColumnSession.ts new file mode 100644 index 00000000..53519d34 --- /dev/null +++ b/packages/ui/src/components/chat/chatColumnSession.ts @@ -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(null); + +export const useChatColumnSession = (): ChatColumnSession | null => React.useContext(ChatColumnSessionContext); diff --git a/packages/ui/src/hooks/useAssistantStatus.ts b/packages/ui/src/hooks/useAssistantStatus.ts index 414e1981..3a3c7edc 100644 --- a/packages/ui/src/hooks/useAssistantStatus.ts +++ b/packages/ui/src/hooks/useAssistantStatus.ts @@ -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 ?? '', diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index fd9ffefe..753ae66e 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -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