From 24dfd1b0d5d9cd47adb616d483f94368d205c7e2 Mon Sep 17 00:00:00 2001 From: "tomtom.bot" <321199632+tomtom-bot7@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:06:09 +0000 Subject: [PATCH 1/3] fix(chat): keep empty soft-swap centered until first turn After /change, mid-switch visibility could still dock the composer before remount reset finished, killing first-message flight origins. Prefer cache emptiness in syncComposerPhase, treat known-empty mid- switch as not visible, and reset session flight flags in $effect.pre. --- cometline/src/lib/components/ChatView.svelte | 10 ++++++---- .../conversation/conversation-controller.test.ts | 16 ++++++++++++++++ .../lib/conversation/conversation-controller.ts | 10 ++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/cometline/src/lib/components/ChatView.svelte b/cometline/src/lib/components/ChatView.svelte index e3a373ab..0fc9ec9f 100644 --- a/cometline/src/lib/components/ChatView.svelte +++ b/cometline/src/lib/components/ChatView.svelte @@ -137,9 +137,9 @@ // 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: known-empty targets stay hero. Only treat as visible when + // cache says the destination has content (or we have a synced snapshot). + if (chatStore.getCachedItemCount(sessionId) === 0) return false; if (!snapshotSynced) return true; return snapshotItems.length > 0; }); @@ -203,7 +203,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(); diff --git a/cometline/src/lib/conversation/conversation-controller.test.ts b/cometline/src/lib/conversation/conversation-controller.test.ts index 310d7a75..d935f319 100644 --- a/cometline/src/lib/conversation/conversation-controller.test.ts +++ b/cometline/src/lib/conversation/conversation-controller.test.ts @@ -639,6 +639,22 @@ 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('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..e19b24a0 100644 --- a/cometline/src/lib/conversation/conversation-controller.ts +++ b/cometline/src/lib/conversation/conversation-controller.ts @@ -267,6 +267,16 @@ export function createConversationController( if (chatStore.sessionID !== deps.getSessionId()) return; if (firstTurnActive) return; + const sessionId = deps.getSessionId(); + const empty = chatStore.getCachedItemCount(sessionId) === 0; + // Soft /change into an empty fork: mid-switch visibility flags must not + // dock after we just centered — emptiness wins until real content or + // first-turn prepare docks intentionally. + if (empty && !awaitingFirstAssistant) { + shellStore.centerComposer(); + return; + } + if (hasVisibleConversation) { shellStore.dockComposer(); } else if (!awaitingFirstAssistant) { From 39023d92d2aed0f5b749f59a0497ef97d339e3d3 Mon Sep 17 00:00:00 2001 From: "tomtom.bot" <321199632+tomtom-bot7@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:11:07 +0000 Subject: [PATCH 2/3] fix(chat): ignore fork status notes for first-turn dock /change AppendSystemMessage becomes a status row; raw item count made empty forks look non-empty, docking the composer and skipping FirstTurnFlight. Gate first-turn and composer phase on user/assistant turns via hasCachedConversationTurns. --- cometline/src/lib/components/ChatView.svelte | 14 +++--- .../conversation-controller.test.ts | 46 ++++++++++++++++++- .../conversation/conversation-controller.ts | 16 +++---- cometline/src/lib/stores/chat.svelte.ts | 9 ++++ 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/cometline/src/lib/components/ChatView.svelte b/cometline/src/lib/components/ChatView.svelte index 0fc9ec9f..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; } - // Mid-switch: known-empty targets stay hero. Only treat as visible when - // cache says the destination has content (or we have a synced snapshot). - if (chatStore.getCachedItemCount(sessionId) === 0) return false; + // 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; }); @@ -214,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 d935f319..5c1626e2 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({ items: [] }), + listChildSessions: vi.fn().mockResolvedValue({ sessions: [] }) })); describe('createConversationController', () => { @@ -25,6 +27,7 @@ describe('createConversationController', () => { sessionStore.setSessions([]); resetConversationTurnQueuesForTests(); shellStore.centerComposer(); + vi.mocked(getSessionMessages).mockResolvedValue({ items: [] }); vi.mocked(getSession).mockResolvedValue({ id: 'sess-1', workspace_id: 'ws-1', @@ -655,6 +658,45 @@ describe('createConversationController', () => { expect(chatStore.getCachedItemCount('sess-1')).toBe(0); }); + it('treats fork status-only transcript as firstTurn and keeps composer centered', async () => { + vi.mocked(getSessionMessages).mockResolvedValue({ + 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 e19b24a0..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(); @@ -268,10 +268,10 @@ export function createConversationController( if (firstTurnActive) return; const sessionId = deps.getSessionId(); - const empty = chatStore.getCachedItemCount(sessionId) === 0; - // Soft /change into an empty fork: mid-switch visibility flags must not - // dock after we just centered — emptiness wins until real content or - // first-turn prepare docks intentionally. + 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; 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, From 5142facd91e5936b3783a3fa2efbc1280229d0b5 Mon Sep 17 00:00:00 2001 From: "tomtom.bot" <321199632+tomtom-bot7@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:14:34 +0000 Subject: [PATCH 3/3] test(chat): include session_id in transcript mocks svelte-check requires TranscriptResponse.session_id; CI failed on the fork status-only controller test mocks. --- .../src/lib/conversation/conversation-controller.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cometline/src/lib/conversation/conversation-controller.test.ts b/cometline/src/lib/conversation/conversation-controller.test.ts index 5c1626e2..8a7b9f37 100644 --- a/cometline/src/lib/conversation/conversation-controller.test.ts +++ b/cometline/src/lib/conversation/conversation-controller.test.ts @@ -16,7 +16,7 @@ type FlightContext = Parameters ({ getSession: vi.fn().mockResolvedValue({ id: 'sess-1', title: 'Updated' }), - getSessionMessages: vi.fn().mockResolvedValue({ items: [] }), + getSessionMessages: vi.fn().mockResolvedValue({ session_id: 'sess-1', items: [] }), listChildSessions: vi.fn().mockResolvedValue({ sessions: [] }) })); @@ -27,7 +27,7 @@ describe('createConversationController', () => { sessionStore.setSessions([]); resetConversationTurnQueuesForTests(); shellStore.centerComposer(); - vi.mocked(getSessionMessages).mockResolvedValue({ items: [] }); + vi.mocked(getSessionMessages).mockResolvedValue({ session_id: 'sess-1', items: [] }); vi.mocked(getSession).mockResolvedValue({ id: 'sess-1', workspace_id: 'ws-1', @@ -660,6 +660,7 @@ describe('createConversationController', () => { it('treats fork status-only transcript as firstTurn and keeps composer centered', async () => { vi.mocked(getSessionMessages).mockResolvedValue({ + session_id: 'sess-1', items: [ { type: 'system',