diff --git a/cometline/src/lib/components/ChatView.svelte b/cometline/src/lib/components/ChatView.svelte index e3a373ab..04d41be4 100644 --- a/cometline/src/lib/components/ChatView.svelte +++ b/cometline/src/lib/components/ChatView.svelte @@ -137,9 +137,8 @@ // loads would otherwise dock the composer and skip FirstTurnFlight. return chatStore.items.length > 0; } - // Store is still bound to a previous session (mid-switch). Before our - // first sync, assume visible when we do not yet know the target is empty - // so we don't flash EmptyChatState while switching to a full transcript. + // Mid-switch: no user/assistant yet stays hero (fork status notes ignored). + if (!chatStore.hasCachedConversationTurns(sessionId)) return false; if (!snapshotSynced) return true; return snapshotItems.length > 0; }); @@ -203,7 +202,9 @@ // destination avatar/thinking indicator appear before the overlay arrives. // Soft swaps (/change fork, sidebar click) keep ChatView mounted — this must // be remount-equivalent so composer phase + flight flags are not stuck until Cmd+R. - $effect(() => { + // Use $effect.pre so stale awaiting/firstTurn flags clear BEFORE syncComposerPhase + // can dock on the previous session's mid-switch visibility. + $effect.pre(() => { void sessionId; untrack(() => { flightAbortController?.abort(); @@ -212,12 +213,13 @@ userBubbleFlight?.dismissParticle(); firstTurnActive = false; firstTurnHandoffPending = false; - const cachedCount = chatStore.getCachedItemCount(sessionId); + const hasTurns = chatStore.hasCachedConversationTurns(sessionId); awaitingFirstAssistant = chatStore.isAwaitingFirstAssistant(sessionId); - // Empty session: explicitly false. Do NOT use `!awaitingFirstAssistant` + // No user/assistant yet: explicitly false. Do NOT use `!awaitingFirstAssistant` // (true when idle) which wrongly marks flight done after soft swaps. - firstTurnFlightDone = cachedCount > 0; - if (cachedCount === 0 && !awaitingFirstAssistant) { + // Fork system notes are status-only and must not mark flight done. + firstTurnFlightDone = hasTurns; + if (!hasTurns && !awaitingFirstAssistant) { snapshotItems = []; snapshotSynced = true; shellStore.centerComposer(); diff --git a/cometline/src/lib/conversation/conversation-controller.test.ts b/cometline/src/lib/conversation/conversation-controller.test.ts index 310d7a75..8a7b9f37 100644 --- a/cometline/src/lib/conversation/conversation-controller.test.ts +++ b/cometline/src/lib/conversation/conversation-controller.test.ts @@ -9,13 +9,15 @@ import { import { chatStore } from '$lib/stores/chat.svelte'; import { sessionStore } from '$lib/stores/session.svelte'; import { shellStore } from '$lib/stores/shell.svelte'; -import { getSession } from '$lib/client/cometmind'; +import { getSession, getSessionMessages } from '$lib/client/cometmind'; type FlightPayload = Parameters[0]; type FlightContext = Parameters[1]; vi.mock('$lib/client/cometmind', () => ({ - getSession: vi.fn().mockResolvedValue({ id: 'sess-1', title: 'Updated' }) + getSession: vi.fn().mockResolvedValue({ id: 'sess-1', title: 'Updated' }), + getSessionMessages: vi.fn().mockResolvedValue({ session_id: 'sess-1', items: [] }), + listChildSessions: vi.fn().mockResolvedValue({ sessions: [] }) })); describe('createConversationController', () => { @@ -25,6 +27,7 @@ describe('createConversationController', () => { sessionStore.setSessions([]); resetConversationTurnQueuesForTests(); shellStore.centerComposer(); + vi.mocked(getSessionMessages).mockResolvedValue({ session_id: 'sess-1', items: [] }); vi.mocked(getSession).mockResolvedValue({ id: 'sess-1', workspace_id: 'ws-1', @@ -639,6 +642,62 @@ describe('createConversationController', () => { centerSpy.mockRestore(); }); + it('syncComposerPhase keeps empty session centered even when hasVisibleConversation is true', () => { + chatStore.bindSession('sess-1'); + const { controller } = createDeps({ hasVisibleConversation: true }); + controller.bindSession(); + shellStore.centerComposer(); + + controller.syncComposerPhase({ + hasVisibleConversation: true, + firstTurnActive: false, + awaitingFirstAssistant: false + }); + + expect(shellStore.composerPhase).toBe('centered'); + expect(chatStore.getCachedItemCount('sess-1')).toBe(0); + }); + + it('treats fork status-only transcript as firstTurn and keeps composer centered', async () => { + vi.mocked(getSessionMessages).mockResolvedValue({ + session_id: 'sess-1', + items: [ + { + type: 'system', + text: 'Forked from a session in /old. File tools now operate under /new.' + } + ] + }); + chatStore.bindSession('sess-1'); + await chatStore.loadTranscript('sess-1'); + expect(chatStore.getCachedItemCount('sess-1')).toBe(1); + expect(chatStore.hasCachedConversationTurns('sess-1')).toBe(false); + + const onUserMessageFlight = vi.fn().mockImplementation((_, ctx: FlightContext) => { + ctx.stageUser('hello', undefined); + }); + const { controller, send } = createDeps({ + hasVisibleConversation: true, + flight: { onUserMessageFlight } + }); + controller.bindSession(); + expect(shellStore.composerPhase).toBe('centered'); + + controller.syncComposerPhase({ + hasVisibleConversation: true, + firstTurnActive: false, + awaitingFirstAssistant: false + }); + expect(shellStore.composerPhase).toBe('centered'); + + await controller.enqueue('hello'); + expect(onUserMessageFlight).toHaveBeenCalledWith( + 'hello', + expect.objectContaining({ firstTurn: true, sessionId: 'sess-1' }) + ); + expect(send).toHaveBeenCalledWith('sess-1', { text: 'hello' }, { skipUser: true }); + }); + it('runs FirstTurnFlight when hasVisibleConversation is true only due to loading and cache is empty', async () => { chatStore.bindSession('sess-1'); const onUserMessageFlight = vi.fn().mockImplementation((_, ctx: FlightContext) => { diff --git a/cometline/src/lib/conversation/conversation-controller.ts b/cometline/src/lib/conversation/conversation-controller.ts index 80701daa..3d6d5aa1 100644 --- a/cometline/src/lib/conversation/conversation-controller.ts +++ b/cometline/src/lib/conversation/conversation-controller.ts @@ -82,9 +82,9 @@ async function runTurn( const userDisplay = payload.displayText ?? payload.text; const usesFlight = Boolean(deps.flight?.onUserMessageFlight); const isViewing = deps.getSessionId() === turnSessionId; - // Content emptiness — not hasVisibleConversation(). Loading makes the latter - // true on an empty fork/soft swap and would skip FirstTurnFlight. - const firstTurn = chatStore.getCachedItemCount(turnSessionId) === 0; + // No user/assistant yet — not hasVisibleConversation() / raw item count. + // Fork AppendSystemMessage becomes a status row and must not skip FirstTurnFlight. + const firstTurn = !chatStore.hasCachedConversationTurns(turnSessionId); const flightPayload = payload.images?.length ? payload : userDisplay; const contexts = messageContextRefsFromWebContexts(payload.webContexts); let stagedUserId: string | undefined; @@ -211,7 +211,7 @@ export function createConversationController( // content/in-flight dock. Do not dock solely because isLoading — that // leaves the composer stuck docked after /change → empty fork. if ( - chatStore.getCachedItemCount(sessionId) > 0 || + chatStore.hasCachedConversationTurns(sessionId) || chatStore.hasInFlightTurn(sessionId) ) { shellStore.dockComposer(); @@ -267,6 +267,16 @@ export function createConversationController( if (chatStore.sessionID !== deps.getSessionId()) return; if (firstTurnActive) return; + const sessionId = deps.getSessionId(); + const empty = !chatStore.hasCachedConversationTurns(sessionId); + // Soft /change into an empty fork (status-only system note counts as empty): + // mid-switch visibility flags must not dock after we just centered — + // emptiness wins until real user/assistant content or first-turn prepare. + if (empty && !awaitingFirstAssistant) { + shellStore.centerComposer(); + return; + } + if (hasVisibleConversation) { shellStore.dockComposer(); } else if (!awaitingFirstAssistant) { diff --git a/cometline/src/lib/stores/chat.svelte.ts b/cometline/src/lib/stores/chat.svelte.ts index 2998b03b..ab972448 100644 --- a/cometline/src/lib/stores/chat.svelte.ts +++ b/cometline/src/lib/stores/chat.svelte.ts @@ -92,6 +92,14 @@ function createChatStore() { return cachedItemCount(targetSessionID); } + /** User/assistant turns only — fork system notes map to `status` and must not + * count as a real conversation for first-turn / composer dock. */ + function hasCachedConversationTurns(targetSessionID: string) { + return getCachedItems(targetSessionID).some( + (item) => item.type === 'user' || item.type === 'assistant' + ); + } + function getCachedItems(targetSessionID: string) { return sessionCache.get(targetSessionID) ?? []; } @@ -927,6 +935,7 @@ function createChatStore() { hasInFlightTurn, isAwaitingFirstAssistant, getCachedItemCount, + hasCachedConversationTurns, clear, resetTranscript, detachActiveSession,