fix(chat): limit draft transition animation

This commit is contained in:
Bohdan Triapitsyn
2026-08-22 11:32:01 +03:00
parent 80068c634d
commit 556b151492
6 changed files with 145 additions and 43 deletions
@@ -566,6 +566,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
// Session UI state // Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory); const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
const materializedDraftSessionId = useSessionUIStore((s) => s.materializedDraftSessionId);
const clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -1107,8 +1109,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const previousDraftOpenRef = React.useRef(draftOpen); const previousDraftOpenRef = React.useRef(draftOpen);
const previousDraftLayoutVisibleRef = React.useRef(draftOpen); const previousDraftLayoutVisibleRef = React.useRef(draftOpen);
const [draftExitAnimating, setDraftExitAnimating] = React.useState(false); const [draftExitAnimating, setDraftExitAnimating] = React.useState(false);
const shouldAnimateDraftTransition = Boolean(
currentSessionId && materializedDraftSessionId === currentSessionId,
);
const draftPresentationExiting = draftExitAnimating const draftPresentationExiting = draftExitAnimating
|| (previousDraftOpenRef.current && !draftOpen && Boolean(currentSessionId)); || (previousDraftOpenRef.current && !draftOpen && shouldAnimateDraftTransition);
const draftLayoutVisible = draftOpen || draftPresentationExiting; const draftLayoutVisible = draftOpen || draftPresentationExiting;
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
@@ -1116,12 +1121,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
setDraftExitAnimating(false); setDraftExitAnimating(false);
return; return;
} }
if (!previousDraftOpenRef.current || !currentSessionId) return; if (!previousDraftOpenRef.current || !shouldAnimateDraftTransition) return;
setDraftExitAnimating(true); setDraftExitAnimating(true);
const timeoutId = window.setTimeout(() => setDraftExitAnimating(false), DRAFT_EXIT_DURATION_MS); const timeoutId = window.setTimeout(() => setDraftExitAnimating(false), DRAFT_EXIT_DURATION_MS);
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [currentSessionId, draftOpen]); }, [draftOpen, shouldAnimateDraftTransition]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
previousDraftOpenRef.current = draftOpen; previousDraftOpenRef.current = draftOpen;
@@ -1139,7 +1144,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
&& Boolean(currentSessionId); && Boolean(currentSessionId);
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
if (leftDraftLayout && previousRect && !reduceMotion && !useCompactDraftLayout && !isDesktopExpandedInput) { const shouldMoveComposer = leftDraftLayout && shouldAnimateDraftTransition;
if (shouldMoveComposer && previousRect && !reduceMotion && !useCompactDraftLayout && !isDesktopExpandedInput) {
const deltaX = previousRect.left - currentRect.left; const deltaX = previousRect.left - currentRect.left;
const deltaY = previousRect.top - currentRect.top; const deltaY = previousRect.top - currentRect.top;
composerSlot.animate( composerSlot.animate(
@@ -1150,10 +1156,19 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
{ duration: COMPOSER_MOVE_DURATION_MS, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, { duration: COMPOSER_MOVE_DURATION_MS, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
); );
} }
previousComposerRectRef.current = currentRect; previousComposerRectRef.current = currentRect;
previousDraftLayoutVisibleRef.current = draftLayoutVisible; previousDraftLayoutVisibleRef.current = draftLayoutVisible;
}, [currentSessionId, draftLayoutVisible, isDesktopExpandedInput, useCompactDraftLayout]); if (leftDraftLayout && currentSessionId) {
clearMaterializedDraftSession(currentSessionId);
}
}, [
clearMaterializedDraftSession,
currentSessionId,
draftLayoutVisible,
isDesktopExpandedInput,
shouldAnimateDraftTransition,
useCompactDraftLayout,
]);
if (!currentSessionId && !draftOpen) { if (!currentSessionId && !draftOpen) {
// The auto-open effect runs on the next tick. Use a neutral background // The auto-open effect runs on the next tick. Use a neutral background
@@ -11,7 +11,9 @@ belongs to one of them.
becomes its first session. Draft-only UI first fades for 120ms while the editor becomes its first session. Draft-only UI first fades for 120ms while the editor
stays in place. The parent then moves the editor to its final session position stays in place. The parent then moves the editor to its final session position
with a 180ms transform-only FLIP animation. Reduced-motion mode skips these with a 180ms transform-only FLIP animation. Reduced-motion mode skips these
transitions. Do not restore separate draft and session composer branches: transitions. `session-ui-store.ts` marks sessions materialized from a submitted
draft, so selecting an existing session while a draft is open switches without
animation. Do not restore separate draft and session composer branches:
remounting the editor loses focus and interrupts the transition. Keep the remounting the editor loses focus and interrupts the transition. Keep the
existing mobile fixed-position rules unchanged. existing mobile fixed-position rules unchanged.
+1 -1
View File
@@ -46,7 +46,7 @@ So:
| `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots | All known directories in the active runtime | | `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots | All known directories in the active runtime |
| `session-ordering.ts` | Ephemeral lifecycle rank used by every user-visible session list | All known sessions in the active runtime | | `session-ordering.ts` | Ephemeral lifecycle rank used by every user-visible session list | All known sessions in the active runtime |
| `session-activity-timing.ts` | Elapsed time of the running turn and of the turn that just finished, plus the persisted starts that survive a reload | All known sessions in the active runtime | | `session-activity-timing.ts` | Elapsed time of the running turn and of the turn that just finished, plus the persisted starts that survive a reload | All known sessions in the active runtime |
| `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state | | `session-ui-store.ts` | Session selection, draft lifecycle, one-shot draft-materialization transition identity, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists | | `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists |
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state | | `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
| `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes | | `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes |
+2 -1
View File
@@ -727,6 +727,7 @@ export async function createSession(
directoryOverride?: string | null, directoryOverride?: string | null,
parentID?: string | null, parentID?: string | null,
metadata?: Record<string, unknown>, metadata?: Record<string, unknown>,
selectionTransition?: "submitted-draft",
): Promise<Session | null> { ): Promise<Session | null> {
try { try {
// Capture the effective directory used for session creation so we can fall // Capture the effective directory used for session creation so we can fall
@@ -747,7 +748,7 @@ export async function createSession(
if (sessionDirectory) { if (sessionDirectory) {
registerSessionDirectory(session.id, sessionDirectory) registerSessionDirectory(session.id, sessionDirectory)
} }
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory) useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory, selectionTransition)
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id) useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
useGlobalSessionsStore.getState().upsertSession(session) useGlobalSessionsStore.getState().upsertSession(session)
return session return session
@@ -200,6 +200,47 @@ describe('session-worktree-store worktree routing', () => {
}); });
}); });
describe('draft materialization transition identity', () => {
beforeEach(() => {
useSessionUIStore.setState({
currentSessionId: null,
currentSessionDirectory: null,
materializedDraftSessionId: null,
newSessionDraft: { open: true, target: 'project', directoryOverride: '/projects/alpha' },
});
});
test('marks and consumes only the submitted draft session', () => {
useSessionUIStore.getState().setCurrentSession(
'session-created',
'/projects/alpha',
'submitted-draft',
);
expect(useSessionUIStore.getState().materializedDraftSessionId).toBe('session-created');
useSessionUIStore.getState().clearMaterializedDraftSession('another-session');
expect(useSessionUIStore.getState().materializedDraftSessionId).toBe('session-created');
useSessionUIStore.getState().clearMaterializedDraftSession('session-created');
expect(useSessionUIStore.getState().materializedDraftSessionId).toBeNull();
});
test('clears the marker when navigating from a draft to an existing session', () => {
useSessionUIStore.getState().setCurrentSession(
'session-created',
'/projects/alpha',
'submitted-draft',
);
useSessionUIStore.setState({
newSessionDraft: { open: true, target: 'project', directoryOverride: '/projects/alpha' },
});
useSessionUIStore.getState().setCurrentSession('session-existing', '/projects/alpha');
expect(useSessionUIStore.getState().materializedDraftSessionId).toBeNull();
});
});
describe('routeMessage directory scoping', () => { describe('routeMessage directory scoping', () => {
test('runs sends in the provided session directory', async () => { test('runs sends in the provided session directory', async () => {
// The session directory travels as an explicit request param (not via // The session directory travels as an explicit request param (not via
+77 -34
View File
@@ -296,6 +296,7 @@ export type SessionHistoryMeta = {
export type SessionUIState = { export type SessionUIState = {
currentSessionId: string | null currentSessionId: string | null
currentSessionDirectory: string | null currentSessionDirectory: string | null
materializedDraftSessionId: string | null
newSessionDraft: NewSessionDraftState newSessionDraft: NewSessionDraftState
abortPromptSessionId: string | null abortPromptSessionId: string | null
abortPromptExpiresAt: number | null abortPromptExpiresAt: number | null
@@ -318,7 +319,12 @@ export type SessionUIState = {
dismissPendingChangesBar: (sessionId: string, signature: string | null) => void dismissPendingChangesBar: (sessionId: string, signature: string | null) => void
// Actions — UI state management // Actions — UI state management
setCurrentSession: (id: string | null, directoryHint?: string | null) => void setCurrentSession: (
id: string | null,
directoryHint?: string | null,
transition?: "submitted-draft",
) => void
clearMaterializedDraftSession: (sessionId: string) => void
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void
restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void
openNewSessionDraft: (options?: Partial<NewSessionDraftState> & { automatic?: boolean }) => void openNewSessionDraft: (options?: Partial<NewSessionDraftState> & { automatic?: boolean }) => void
@@ -357,7 +363,12 @@ export type SessionUIState = {
options?: SendMessageOptions, options?: SendMessageOptions,
) => Promise<void> ) => Promise<void>
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null, metadata?: Record<string, unknown>) => Promise<Session | null> createSession: (
title?: string,
directoryOverride?: string | null,
parentID?: string | null,
metadata?: Record<string, unknown>,
) => Promise<Session | null>
deleteSession: (id: string, options?: DeleteSessionOptions) => Promise<boolean> deleteSession: (id: string, options?: DeleteSessionOptions) => Promise<boolean>
deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }> deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }>
archiveSession: (id: string) => Promise<boolean> archiveSession: (id: string) => Promise<boolean>
@@ -715,6 +726,48 @@ const recoverStaleDraftDirectory = async (openedDraft: NewSessionDraftState): Pr
void activateConfigForDirectory(recovered) void activateConfigForDirectory(recovered)
} }
const createSessionWithDraftLifecycle = async (
title?: string,
directoryOverride?: string | null,
parentID?: string | null,
metadata?: Record<string, unknown>,
selectionTransition?: "submitted-draft",
): Promise<Session | null> => {
const store = useSessionUIStore.getState()
const draft = store.newSessionDraft
const targetFolderId = draft.targetFolderId
try {
const resolved = await resolveCreatableDraftDirectory(draft, directoryOverride)
if (resolved.status === "aborted") return null
const directory = resolved.directory
const session = await createSessionAction(
title,
directory,
parentID ?? null,
metadata,
selectionTransition,
)
if (!session) return null
useSessionUIStore.getState().closeNewSessionDraft()
if (targetFolderId) {
const currentStore = useSessionUIStore.getState()
const scopeDirectory = directory || currentStore.lastLoadedDirectory || session.directory
const scopeKey = getChatsRootFromDirectory(scopeDirectory) ?? scopeDirectory
if (scopeKey) {
useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id)
}
}
return session
} catch (error) {
console.error("[session-ui-store] createSession failed", error)
return null
}
}
export async function materializeOpenDraftSession(selection: { export async function materializeOpenDraftSession(selection: {
providerID: string providerID: string
modelID: string modelID: string
@@ -752,13 +805,14 @@ export async function materializeOpenDraftSession(selection: {
await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId) await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId)
const draftPins = draft.projectContextPins ?? { notes: [], plans: [] } const draftPins = draft.projectContextPins ?? { notes: [], plans: [] }
const created = await store.createSession( const created = await createSessionWithDraftLifecycle(
draft.title, draft.title,
draftDirectoryOverride, draftDirectoryOverride,
draft.parentID ?? null, draft.parentID ?? null,
draftPins.notes.length > 0 || draftPins.plans.length > 0 draftPins.notes.length > 0 || draftPins.plans.length > 0
? { openchamber: { project_context_pins: draftPins } } ? { openchamber: { project_context_pins: draftPins } }
: undefined, : undefined,
"submitted-draft",
) )
if (!created?.id) { if (!created?.id) {
if (isChatDraft && draftDirectoryOverride) { if (isChatDraft && draftDirectoryOverride) {
@@ -796,8 +850,6 @@ export async function materializeOpenDraftSession(selection: {
store.initializeNewOpenChamberSession(created.id, configState.agents ?? []) store.initializeNewOpenChamberSession(created.id, configState.agents ?? [])
store.setCurrentSession(created.id, createdDirectory)
if (draftPermissionAutoAcceptEnabled) { if (draftPermissionAutoAcceptEnabled) {
void import("@/stores/permissionStore") void import("@/stores/permissionStore")
.then(({ usePermissionStore }) => usePermissionStore.getState().setSessionAutoAccept(created.id, true)) .then(({ usePermissionStore }) => usePermissionStore.getState().setSessionAutoAccept(created.id, true))
@@ -838,6 +890,7 @@ const PERSISTED_WORKTREE_MAP = readPersistedWorktreeTopology(runtimeMemoryKey())
export const useSessionUIStore = create<SessionUIState>()((set, get) => ({ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
currentSessionId: null, currentSessionId: null,
currentSessionDirectory: null, currentSessionDirectory: null,
materializedDraftSessionId: null,
newSessionDraft: { ...DEFAULT_DRAFT }, newSessionDraft: { ...DEFAULT_DRAFT },
abortPromptSessionId: null, abortPromptSessionId: null,
abortPromptExpiresAt: null, abortPromptExpiresAt: null,
@@ -856,7 +909,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// setCurrentSession // setCurrentSession
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
setCurrentSession: (id, directoryHint?: string | null) => { setCurrentSession: (id, directoryHint?: string | null, transition?: "submitted-draft") => {
const materializedDraftSessionId = id && transition === "submitted-draft" ? id : null
// Publish the transition identity before closing the draft. Those are two
// separate store updates, and ChatContainer must never observe a closed
// draft with the previous transition identity.
if (get().materializedDraftSessionId !== materializedDraftSessionId) {
set({ materializedDraftSessionId })
}
if (id) { if (id) {
get().closeNewSessionDraft() get().closeNewSessionDraft()
} }
@@ -890,7 +950,10 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// Set the directory together with the session id so chat hooks read the // Set the directory together with the session id so chat hooks read the
// same child store that send/SSE events will update during startup races. // same child store that send/SSE events will update during startup races.
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null }) set({
currentSessionId: id,
currentSessionDirectory: id ? resolvedDir ?? null : null,
})
guessedSelectionSessionId = isGuessedDir && id ? id : null guessedSelectionSessionId = isGuessedDir && id ? id : null
const rememberedDir = isGuessedDir ? null : resolvedDir ?? null const rememberedDir = isGuessedDir ? null : resolvedDir ?? null
writeRuntimeSessionMemory(key, { sessionId: id, directory: rememberedDir }) writeRuntimeSessionMemory(key, { sessionId: id, directory: rememberedDir })
@@ -945,6 +1008,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
} }
}, },
clearMaterializedDraftSession: (sessionId) => {
if (get().materializedDraftSessionId !== sessionId) return
set({ materializedDraftSessionId: null })
},
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => { prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => {
const key = runtimeMemoryKey(apiBaseUrl) const key = runtimeMemoryKey(apiBaseUrl)
const directory = useDirectoryStore.getState().currentDirectory || null const directory = useDirectoryStore.getState().currentDirectory || null
@@ -1663,33 +1731,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// createSession // createSession
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
createSession: async (title, directoryOverride, parentID, metadata) => { createSession: (title, directoryOverride, parentID, metadata) =>
const draft = get().newSessionDraft createSessionWithDraftLifecycle(title, directoryOverride, parentID, metadata),
const targetFolderId = draft.targetFolderId
try {
const resolved = await resolveCreatableDraftDirectory(draft, directoryOverride)
if (resolved.status === "aborted") return null
const dir = resolved.directory
const session = await createSessionAction(title, dir, parentID ?? null, metadata)
if (!session) return null
get().closeNewSessionDraft()
if (targetFolderId) {
const scopeDirectory = dir || get().lastLoadedDirectory || session.directory
const scopeKey = getChatsRootFromDirectory(scopeDirectory) ?? scopeDirectory
if (scopeKey) {
useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id)
}
}
return session
} catch (e) {
console.error("[session-ui-store] createSession failed", e)
return null
}
},
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// deleteSession — calls SDK, SSE event updates child store // deleteSession — calls SDK, SSE event updates child store