fix(chat): limit draft transition animation
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user