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
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
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 setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -1107,8 +1109,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const previousDraftOpenRef = React.useRef(draftOpen);
const previousDraftLayoutVisibleRef = React.useRef(draftOpen);
const [draftExitAnimating, setDraftExitAnimating] = React.useState(false);
const shouldAnimateDraftTransition = Boolean(
currentSessionId && materializedDraftSessionId === currentSessionId,
);
const draftPresentationExiting = draftExitAnimating
|| (previousDraftOpenRef.current && !draftOpen && Boolean(currentSessionId));
|| (previousDraftOpenRef.current && !draftOpen && shouldAnimateDraftTransition);
const draftLayoutVisible = draftOpen || draftPresentationExiting;
React.useLayoutEffect(() => {
@@ -1116,12 +1121,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
setDraftExitAnimating(false);
return;
}
if (!previousDraftOpenRef.current || !currentSessionId) return;
if (!previousDraftOpenRef.current || !shouldAnimateDraftTransition) return;
setDraftExitAnimating(true);
const timeoutId = window.setTimeout(() => setDraftExitAnimating(false), DRAFT_EXIT_DURATION_MS);
return () => window.clearTimeout(timeoutId);
}, [currentSessionId, draftOpen]);
}, [draftOpen, shouldAnimateDraftTransition]);
React.useLayoutEffect(() => {
previousDraftOpenRef.current = draftOpen;
@@ -1139,7 +1144,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
&& Boolean(currentSessionId);
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 deltaY = previousRect.top - currentRect.top;
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)' },
);
}
previousComposerRectRef.current = currentRect;
previousDraftLayoutVisibleRef.current = draftLayoutVisible;
}, [currentSessionId, draftLayoutVisible, isDesktopExpandedInput, useCompactDraftLayout]);
if (leftDraftLayout && currentSessionId) {
clearMaterializedDraftSession(currentSessionId);
}
}, [
clearMaterializedDraftSession,
currentSessionId,
draftLayoutVisible,
isDesktopExpandedInput,
shouldAnimateDraftTransition,
useCompactDraftLayout,
]);
if (!currentSessionId && !draftOpen) {
// 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
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
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
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 |
| `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-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 |
| `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 |
+2 -1
View File
@@ -727,6 +727,7 @@ export async function createSession(
directoryOverride?: string | null,
parentID?: string | null,
metadata?: Record<string, unknown>,
selectionTransition?: "submitted-draft",
): Promise<Session | null> {
try {
// Capture the effective directory used for session creation so we can fall
@@ -747,7 +748,7 @@ export async function createSession(
if (sessionDirectory) {
registerSessionDirectory(session.id, sessionDirectory)
}
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory)
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory, selectionTransition)
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
useGlobalSessionsStore.getState().upsertSession(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', () => {
test('runs sends in the provided session directory', async () => {
// 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 = {
currentSessionId: string | null
currentSessionDirectory: string | null
materializedDraftSessionId: string | null
newSessionDraft: NewSessionDraftState
abortPromptSessionId: string | null
abortPromptExpiresAt: number | null
@@ -318,7 +319,12 @@ export type SessionUIState = {
dismissPendingChangesBar: (sessionId: string, signature: string | null) => void
// 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
restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void
openNewSessionDraft: (options?: Partial<NewSessionDraftState> & { automatic?: boolean }) => void
@@ -357,7 +363,12 @@ export type SessionUIState = {
options?: SendMessageOptions,
) => 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>
deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }>
archiveSession: (id: string) => Promise<boolean>
@@ -715,6 +726,48 @@ const recoverStaleDraftDirectory = async (openedDraft: NewSessionDraftState): Pr
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: {
providerID: string
modelID: string
@@ -752,13 +805,14 @@ export async function materializeOpenDraftSession(selection: {
await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId)
const draftPins = draft.projectContextPins ?? { notes: [], plans: [] }
const created = await store.createSession(
const created = await createSessionWithDraftLifecycle(
draft.title,
draftDirectoryOverride,
draft.parentID ?? null,
draftPins.notes.length > 0 || draftPins.plans.length > 0
? { openchamber: { project_context_pins: draftPins } }
: undefined,
"submitted-draft",
)
if (!created?.id) {
if (isChatDraft && draftDirectoryOverride) {
@@ -796,8 +850,6 @@ export async function materializeOpenDraftSession(selection: {
store.initializeNewOpenChamberSession(created.id, configState.agents ?? [])
store.setCurrentSession(created.id, createdDirectory)
if (draftPermissionAutoAcceptEnabled) {
void import("@/stores/permissionStore")
.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) => ({
currentSessionId: null,
currentSessionDirectory: null,
materializedDraftSessionId: null,
newSessionDraft: { ...DEFAULT_DRAFT },
abortPromptSessionId: null,
abortPromptExpiresAt: null,
@@ -856,7 +909,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// ---------------------------------------------------------------------------
// 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) {
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
// 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
const rememberedDir = isGuessedDir ? null : resolvedDir ?? null
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) => {
const key = runtimeMemoryKey(apiBaseUrl)
const directory = useDirectoryStore.getState().currentDirectory || null
@@ -1663,33 +1731,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// ---------------------------------------------------------------------------
// createSession
// ---------------------------------------------------------------------------
createSession: async (title, directoryOverride, parentID, metadata) => {
const draft = get().newSessionDraft
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
}
},
createSession: (title, directoryOverride, parentID, metadata) =>
createSessionWithDraftLifecycle(title, directoryOverride, parentID, metadata),
// ---------------------------------------------------------------------------
// deleteSession — calls SDK, SSE event updates child store