Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
getSessionStreaming: () => sessionStreaming,
getLastUserId: () => lastUserId,
getUserMessageCount: () => userMessageCount,
getItems: () => threadItems,
getIsLoading: () => chatStore.isLoading,
sessionHasCachedTranscript,
getScroller: () => scrollerEl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}
<FirstTurnAssistantSlot
Expand Down
36 changes: 36 additions & 0 deletions cometline/src/lib/features/chat/thread-scroll.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,42 @@ describe('createThreadScroll', () => {
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: {
Expand Down
19 changes: 15 additions & 4 deletions cometline/src/lib/features/chat/thread-scroll.svelte.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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. */
Expand Down Expand Up @@ -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();

Expand All @@ -418,15 +427,17 @@ 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;
}

// 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);
});
Expand Down
18 changes: 18 additions & 0 deletions cometline/src/lib/features/chat/thread-turns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
15 changes: 15 additions & 0 deletions cometline/src/lib/features/chat/thread-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Loading