diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 3aff86c8..433db7fa 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -566,6 +566,8 @@ export const ChatContainer: React.FC = ({ // 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 = ({ 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 = ({ 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 = ({ && 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 = ({ { 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 diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 6ef1fd31..f800230b 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -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. diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index e0d61d26..0b51b525 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -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 | diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 7ab6e370..027e8f27 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -727,6 +727,7 @@ export async function createSession( directoryOverride?: string | null, parentID?: string | null, metadata?: Record, + selectionTransition?: "submitted-draft", ): Promise { 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 diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 6de1cf68..bff2e2f2 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -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 diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index f11b6daa..750f0724 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -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 & { automatic?: boolean }) => void @@ -357,7 +363,12 @@ export type SessionUIState = { options?: SendMessageOptions, ) => Promise - createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null, metadata?: Record) => Promise + createSession: ( + title?: string, + directoryOverride?: string | null, + parentID?: string | null, + metadata?: Record, + ) => Promise deleteSession: (id: string, options?: DeleteSessionOptions) => Promise deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }> archiveSession: (id: string) => Promise @@ -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, + selectionTransition?: "submitted-draft", +): Promise => { + 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()((set, get) => ({ currentSessionId: null, currentSessionDirectory: null, + materializedDraftSessionId: null, newSessionDraft: { ...DEFAULT_DRAFT }, abortPromptSessionId: null, abortPromptExpiresAt: null, @@ -856,7 +909,14 @@ export const useSessionUIStore = create()((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()((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()((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()((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