diff --git a/cometline/src/lib/features/chat/components/ChatThread.svelte b/cometline/src/lib/features/chat/components/ChatThread.svelte index ea2ac254..b296d524 100644 --- a/cometline/src/lib/features/chat/components/ChatThread.svelte +++ b/cometline/src/lib/features/chat/components/ChatThread.svelte @@ -138,6 +138,7 @@ getSessionStreaming: () => sessionStreaming, getLastUserId: () => lastUserId, getUserMessageCount: () => userMessageCount, + getItems: () => threadItems, getIsLoading: () => chatStore.isLoading, sessionHasCachedTranscript, getScroller: () => scrollerEl, diff --git a/cometline/src/lib/features/chat/components/ChatThreadTurn.svelte b/cometline/src/lib/features/chat/components/ChatThreadTurn.svelte index b5531038..a587f937 100644 --- a/cometline/src/lib/features/chat/components/ChatThreadTurn.svelte +++ b/cometline/src/lib/features/chat/components/ChatThreadTurn.svelte @@ -13,6 +13,7 @@ type ThreadVisibilityContext } from '#lib/features/chat/thread-visibility.js'; import { startsSpeakerRun } from '#lib/features/chat/thread-view-helpers.js'; + import { isFollowUpUserMessage } from '#lib/features/chat/thread-turns.js'; import type { VisibleTurnEntry } from '#lib/features/chat/thread-virtual.svelte.js'; import FirstTurnAssistantSlot from '#lib/features/chat/components/FirstTurnAssistantSlot.svelte'; import UserMessageRow from '#lib/features/chat/components/UserMessageRow.svelte'; @@ -90,7 +91,7 @@ )} copiedId={stackContext.copiedId} onCopyMessage={stackContext.onCopyMessage} - flyOnReveal={turn.user.id !== visibilityContext.firstUserId} + flyOnReveal={isFollowUpUserMessage(visibilityContext.threadItems, turn.user.id)} /> {#if showFirstTurnAvatarSlot(visibilityContext) && turn.user.id === visibilityContext.firstUserId} { expect(screen.getByTestId('thread-scroll').dataset.initialPaint).toBe('false'); }); + it('pins the first follow-up after a forked transcript that starts with a status note', async () => { + const view = render(ThreadScrollHarness, { + props: { + items: [ + { id: 'note', type: 'status', text: 'Forked from a session in /old.' }, + { id: 'u1', type: 'user', text: 'first' }, + { id: 'a1', type: 'assistant', text: 'reply' } + ], + loading: false, + synced: true, + streaming: false, + cached: true + } + }); + await settleEffects(); + expect(screen.getByTestId('thread-scroll').dataset.initialPaint).toBe('false'); + + scrollIntoView.mockClear(); + await view.rerender({ + items: [ + { id: 'note', type: 'status', text: 'Forked from a session in /old.' }, + { id: 'u1', type: 'user', text: 'first' }, + { id: 'a1', type: 'assistant', text: 'reply' }, + { id: 'u2', type: 'user', text: 'follow up' }, + { id: 'a2', type: 'assistant', text: '', pending: true } + ], + streaming: true + }); + await settleEffects(); + + await waitFor(() => + expect(screen.getByTestId('thread-scroll').dataset.activePinnedUserId).toBe('u2') + ); + expect(scrollIntoView).toHaveBeenCalled(); + }); + it('pins follow-ups on a fresh session instead of wiping the canvas while streaming', async () => { const view = render(ThreadScrollHarness, { props: { diff --git a/cometline/src/lib/features/chat/thread-scroll.svelte.ts b/cometline/src/lib/features/chat/thread-scroll.svelte.ts index b15c9be1..1ad53d79 100644 --- a/cometline/src/lib/features/chat/thread-scroll.svelte.ts +++ b/cometline/src/lib/features/chat/thread-scroll.svelte.ts @@ -1,6 +1,10 @@ import { tick, untrack } from 'svelte'; import type { ChatItem } from '#lib/stores/chat.svelte.js'; -import { activeTurnMinHeight, transcriptHasLeadingOrphans } from './thread-turns'; +import { + activeTurnMinHeight, + isFollowUpUserMessage, + transcriptHasLeadingOrphans +} from './thread-turns'; import { buildScrollKey, followUpPinScrollMargin, shouldShowJumpToBottom } from './thread-scroll'; import { THREAD_HYDRATION_FAILSAFE_MS, scrollTopAfterPrepend } from './thread-virtualizer'; @@ -15,6 +19,8 @@ export interface ThreadScrollDeps { getSessionStreaming: () => boolean; getLastUserId: () => string | null; getUserMessageCount: () => number; + /** Flat transcript, including leading status rows a fork may prepend. */ + getItems?: () => readonly ChatItem[]; getIsLoading: () => boolean; sessionHasCachedTranscript: (sessionId: string) => boolean; /** Optional — when provided, the controller tracks the bound scroller. */ @@ -406,7 +412,10 @@ export function createThreadScroll(deps: ThreadScrollDeps) { $effect(() => { const userId = deps.getLastUserId(); const hydrating = isInitialTranscriptPaint; - const count = deps.getUserMessageCount(); + const items = deps.getItems?.() ?? deps.getThreadItems(); + const followUp = userId + ? isFollowUpUserMessage(items, userId) + : deps.getUserMessageCount() > 1; const vh = viewportHeight; const streaming = deps.getSessionStreaming(); @@ -418,7 +427,7 @@ export function createThreadScroll(deps: ThreadScrollDeps) { // Transcript refresh may remap the pinned user id after the stream ends. // Retarget the reply canvas without scrolling again. - if (activePinnedUserId !== null && !streaming && count > 1 && !hydrating) { + if (activePinnedUserId !== null && !streaming && followUp && !hydrating) { activePinnedUserId = userId; lastScrolledUserId = userId; return; @@ -426,7 +435,9 @@ export function createThreadScroll(deps: ThreadScrollDeps) { // Do not consume the id until we can actually present — retry when // hydration finishes, viewport is measured, or streaming starts. - if (hydrating || count <= 1 || vh <= 0 || !streaming) return; + // A leading fork status note is not a user turn, so count alone would + // skip the pin for the first follow-up after /change. + if (hydrating || !followUp || vh <= 0 || !streaming) return; presentFollowUpTurn(userId); }); diff --git a/cometline/src/lib/features/chat/thread-turns.test.ts b/cometline/src/lib/features/chat/thread-turns.test.ts index 66073bed..6ab8cf3d 100644 --- a/cometline/src/lib/features/chat/thread-turns.test.ts +++ b/cometline/src/lib/features/chat/thread-turns.test.ts @@ -2,10 +2,28 @@ import { describe, expect, it } from 'vitest'; import { activeTurnMinHeight, groupThreadItemsIntoTurns, + isFollowUpUserMessage, transcriptHasLeadingOrphans } from './thread-turns'; import type { ChatItem } from '#lib/stores/chat.svelte.js'; +describe('isFollowUpUserMessage', () => { + const forked: ChatItem[] = [ + { id: 'note', type: 'status', text: 'Forked from a session in /old.' }, + { id: 'u1', type: 'user', text: 'first' }, + { id: 'a1', type: 'assistant', text: 'reply' }, + { id: 'u2', type: 'user', text: 'follow up' } + ]; + + it('treats the first user after a fork status note as the opening turn', () => { + expect(isFollowUpUserMessage(forked, 'u1')).toBe(false); + }); + + it('treats a later user in a forked transcript as a follow-up', () => { + expect(isFollowUpUserMessage(forked, 'u2')).toBe(true); + }); +}); + describe('groupThreadItemsIntoTurns', () => { it('groups follow-up items under the preceding user message', () => { const items: ChatItem[] = [ diff --git a/cometline/src/lib/features/chat/thread-turns.ts b/cometline/src/lib/features/chat/thread-turns.ts index e639cafc..da63971d 100644 --- a/cometline/src/lib/features/chat/thread-turns.ts +++ b/cometline/src/lib/features/chat/thread-turns.ts @@ -27,6 +27,21 @@ export function transcriptHasLeadingOrphans(items: readonly ChatItem[]): boolean return items.length > 0 && items[0].type !== 'user'; } +/** + * True when an earlier user message exists. Leading status rows (the system + * note `/change` appends after a fork) do not count, so the first follow-up + * in a forked transcript still gets the pin + fly-in. + */ +export function isFollowUpUserMessage(items: readonly ChatItem[], userId: string): boolean { + let seenUser = false; + for (const item of items) { + if (item.type !== 'user') continue; + if (item.id === userId) return seenUser; + seenUser = true; + } + return false; +} + /** Group a flat transcript into user-anchored turns (plus a leading orphan turn if needed). */ export function groupThreadItemsIntoTurns(items: readonly ChatItem[]): ThreadTurn[] { const turns: ThreadTurn[] = [];