feat(web): add scroll-up lazy loading for older session messages and fix new-messages pill (#893)

* feat(web): add scroll-up lazy loading for older session messages

* fix(web): keep new-messages pill above the composer dock

* fix(web): observe top sentinel after DOM flush in ChatPane

* fix(web): avoid eager lazy-load on open and suppress pill during history prepend

* fix(web): gate scroll-key watcher and skip scroll restore on session switch

* fix(web): restore scroll position from stable top anchor when prepending history

* fix(web): preserve new-message pill during history lazy load

* fix(web): resolve lint regressions in lazy load tests

* fix(web): harden session lazy-load retry and scroll restore

* fix(web): treat same-turn history prepends as non-bottom updates
This commit is contained in:
qer 2026-06-18 20:12:21 +08:00 committed by GitHub
parent 495fe8c674
commit d7ec05686a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 620 additions and 19 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Add scroll-up lazy loading for older messages in the web chat session view, and fix the "new messages" pill overlapping the composer dock.

View file

@ -1026,6 +1026,10 @@ function openPr(url: string): void {
:file-reload-key="client.activeSessionId.value"
:session-loading="client.sessionLoading.value"
:compaction="client.compaction.value"
:has-more-messages="client.hasMoreMessages.value"
:loading-more="client.loadingMoreMessages.value"
:loading-more-error="client.loadMoreMessagesError.value"
:load-older-messages="client.loadOlderMessages"
:workspace-name="client.visibleWorkspace.value?.name"
:workspace-root="client.visibleWorkspace.value?.root ?? client.status.value.cwd"
:git-diff-stats="client.gitDiffStats.value"

View file

@ -1,6 +1,6 @@
<!-- apps/kimi-web/src/components/ChatPane.vue -->
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ChatTurn, ApprovalBlock, FilePreviewRequest, ToolMedia, TurnBlock } from '../types';
import ToolCall from './ToolCall.vue';
@ -71,17 +71,92 @@ const props = withDefaults(
* Completion is a persistent divider turn (role 'compaction') in `turns`.
*/
compaction?: { status: 'running' } | null;
/**
* True when there are older messages available above the current viewport.
*/
hasMoreMessages?: boolean;
/**
* True while older messages are being fetched (rendered at the top of the pane).
*/
loadingMore?: boolean;
/**
* True when the last older-message fetch failed; blocks automatic sentinel retries.
*/
loadingMoreError?: boolean;
/**
* True when the conversation pane is currently following the bottom (auto-scroll).
* Used to prevent the top sentinel from eagerly loading older messages on open.
*/
isFollowing?: boolean;
/**
* @deprecated No longer used Composer is rendered by ConversationPane.
*/
}>(),
{ approvals: () => [], bubble: false, mobile: false, running: false, sending: false, fastMoon: false, compaction: null },
{
approvals: () => [],
bubble: false,
mobile: false,
running: false,
sending: false,
fastMoon: false,
compaction: null,
hasMoreMessages: false,
loadingMore: false,
loadingMoreError: false,
isFollowing: false,
},
);
// Bubble layout is active on phones AND on the Modern desktop theme. ThinkingBlock
// / ToolCall use their soft "bubble" rendering in the same condition.
const childBubble = computed(() => props.bubble || props.mobile);
// Top sentinel for lazy-loading older messages. Visible when there are older
// messages or while a page is loading; the IntersectionObserver fires as soon
// as the user scrolls (or pans) near the top of the transcript.
const topSentinelRef = ref<HTMLElement | null>(null);
let topSentinelObserver: IntersectionObserver | null = null;
function observeTopSentinel(): void {
if (!topSentinelRef.value || typeof IntersectionObserver === 'undefined') return;
topSentinelObserver?.disconnect();
topSentinelObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0];
// Only trigger when the user has intentionally scrolled away from the
// bottom (isFollowing=false) and the initial snapshot is no longer loading.
if (
entry?.isIntersecting &&
props.hasMoreMessages &&
!props.loadingMore &&
!props.loadingMoreError &&
!props.sessionLoading &&
!props.isFollowing
) {
emit('loadOlderMessages');
}
},
{ root: null, rootMargin: '200px 0px 0px 0px', threshold: 0 },
);
topSentinelObserver.observe(topSentinelRef.value);
}
onMounted(observeTopSentinel);
onUnmounted(() => {
topSentinelObserver?.disconnect();
topSentinelObserver = null;
});
watch(
() => [props.hasMoreMessages, props.loadingMore, props.loadingMoreError],
() => {
// Re-attach the observer after a load so that a still-visible sentinel
// (e.g. the page was not tall enough to scroll) triggers another page.
// Wait for the next render tick because the sentinel is rendered by v-if
// and may not exist when this watcher first fires.
void nextTick().then(observeTopSentinel);
},
);
// The id of the turn that is actively streaming: the last assistant turn while
// the session is running. Its Markdown renders with `streaming` (final=false);
// every other turn renders statically.
@ -111,6 +186,8 @@ const emit = defineEmits<{
openAgent: [target: { turnId: string; blockIndex: number; memberId: string }];
/** Edit + resend the last user message (parent undoes, then refills composer). */
editMessage: [text: string];
/** Fetch the next older page of messages (triggered by top sentinel visibility or click). */
loadOlderMessages: [];
}>();
// Id of the most recent user turn the only one offered an "edit & resend"
@ -441,6 +518,26 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
</div>
<div v-else-if="turns.length === 0 && (!approvals || approvals.length === 0)" class="chat-empty" />
<div
v-if="hasMoreMessages || loadingMore"
ref="topSentinelRef"
class="top-sentinel"
:class="{ 'top-sentinel-loading': loadingMore }"
>
<button
v-if="!loadingMore"
type="button"
class="top-sentinel-btn"
@click="emit('loadOlderMessages')"
>
{{ t('conversation.loadOlder') }}
</button>
<span v-else class="top-sentinel-text">
<span class="dot-pulse" aria-hidden="true" />
{{ t('conversation.loadingOlder') }}
</span>
</div>
<template v-for="(turn, ti) in turns" :key="turn.id">
<!-- User turn right-aligned soft-blue bubble (undo affordance lives
outside the bubble with an inline confirm step). -->
@ -596,6 +693,26 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
the dock, moved here by ConversationPane when workspaceEmpty). -->
<div v-else-if="turns.length === 0 && (!approvals || approvals.length === 0)" class="chat-empty" />
<div
v-if="hasMoreMessages || loadingMore"
ref="topSentinelRef"
class="top-sentinel"
:class="{ 'top-sentinel-loading': loadingMore }"
>
<button
v-if="!loadingMore"
type="button"
class="top-sentinel-btn"
@click="emit('loadOlderMessages')"
>
{{ t('conversation.loadOlder') }}
</button>
<span v-else class="top-sentinel-text">
<span class="dot-pulse" aria-hidden="true" />
{{ t('conversation.loadingOlder') }}
</span>
</div>
<template v-for="(turn, ti) in turns" :key="turn.id">
<!-- Compaction divider full-width separator, no gutter number. -->
<div v-if="turn.role === 'compaction'" class="compact-divider turn-anchor" :data-turn-id="turn.id" role="separator">
@ -1396,4 +1513,38 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
}
}
/* Top sentinel for lazy-loading older messages */
.top-sentinel {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0;
min-height: 28px;
}
.top-sentinel-loading {
opacity: 0.8;
}
.top-sentinel-btn {
appearance: none;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
font-size: var(--ui-font-size-sm);
padding: 4px 12px;
border-radius: 999px;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
}
.top-sentinel-btn:hover {
color: var(--fg);
border-color: var(--fg);
}
.top-sentinel-text {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: var(--ui-font-size-sm);
}
</style>

View file

@ -48,6 +48,14 @@ const props = defineProps<{
sessionLoading?: boolean;
/** Live compaction state of the active session (non-null while running). */
compaction?: { status: 'running' } | null;
/** Whether there are older messages available to load when scrolling up. */
hasMoreMessages?: boolean;
/** True while older messages are being fetched (scroll-up lazy load). */
loadingMore?: boolean;
/** True when the last older-message fetch failed; blocks sentinel auto-retry. */
loadingMoreError?: boolean;
/** Callback to fetch the next older page of messages. */
loadOlderMessages?: (sessionId: string) => Promise<void>;
/** Available models for the quick-switch dropdown in the composer toolbar. */
models?: AppModel[];
/** Starred model ids shown at the top of the composer's quick-switch dropdown. */
@ -241,10 +249,10 @@ function tocTitle(turn: ChatTurn): string {
if (turn.role === 'compaction') return t('conversation.compactedPlain');
if (turn.role === 'user') {
if (turn.skillActivation) return `/${turn.skillActivation.name}`;
const text = turn.text.trim().replace(/\s+/g, ' ');
const text = turn.text.trim().replaceAll(/\s+/g, ' ');
return text.length > 0 ? text : 'user';
}
const text = (turn.text || turn.thinking || '').trim().replace(/\s+/g, ' ');
const text = (turn.text || turn.thinking || '').trim().replaceAll(/\s+/g, ' ');
if (text.length > 0) return text;
if ((turn.tools?.length ?? 0) > 0) return `${turn.tools!.length} tools`;
return 'kimi';
@ -309,7 +317,7 @@ function updateTocViewport(): void {
const pane = panesRef.value;
if (!pane) return;
const anchors = pane.querySelectorAll<HTMLElement>('.turn-anchor[data-turn-id]');
if (!anchors.length) return;
if (anchors.length === 0) return;
const paneRect = pane.getBoundingClientRect();
const paneMiddle = paneRect.height / 2;
let bestId: string | null = null;
@ -372,6 +380,7 @@ const pendingApproval = computed(() =>
const panesRef = ref<HTMLElement | null>(null);
const dockRef = ref<HTMLElement | null>(null);
const panesScrollbarWidth = ref(0);
const dockHeight = ref(0);
const chatDockStyle = computed(() => ({
'--panes-scrollbar-width': `${panesScrollbarWidth.value}px`,
}));
@ -387,6 +396,7 @@ function toHtmlEl(el: RefArg): HTMLElement | null {
function updatePanesScrollbarWidth(): void {
const el = panesRef.value;
panesScrollbarWidth.value = el ? Math.max(0, el.offsetWidth - el.clientWidth) : 0;
dockHeight.value = dockRef.value?.offsetHeight ?? 0;
}
function bindChatPane(el: RefArg): void {
@ -475,9 +485,72 @@ function scrollToBottom(smooth = false): void {
showPill.value = false;
}
function findTopAnchor(
container: HTMLElement,
scrollTop: number,
): { id: string; top: number } | null {
const anchors = container.querySelectorAll<HTMLElement>('.turn-anchor');
for (const anchor of anchors) {
if (anchor.offsetTop >= scrollTop) {
const id = anchor.dataset.turnId;
if (id) return { id, top: anchor.offsetTop };
}
}
return null;
}
async function handleLoadOlderMessages(): Promise<void> {
if (
!props.sessionId ||
!props.loadOlderMessages ||
props.loadingMore ||
!props.hasMoreMessages
) {
return;
}
const requestedSessionId = props.sessionId;
const el = panesRef.value;
const oldTop = el?.scrollTop ?? 0;
const oldHeight = el?.scrollHeight ?? 0;
const oldAnchor = el ? findTopAnchor(el, oldTop) : null;
historyLoadInProgress.value = true;
try {
await props.loadOlderMessages(requestedSessionId);
await nextTick();
} finally {
historyLoadInProgress.value = false;
}
// If the user switched sessions while the request was in flight, do not
// restore scroll position on the newly selected session's pane.
if (props.sessionId !== requestedSessionId) return;
const el2 = panesRef.value;
if (!el2) return;
// Restore scroll position using a stable anchor near the old viewport top.
// This isolates height inserted above the anchor and ignores any new bottom
// content (e.g. streaming assistant turns) that arrived during the request.
let delta = 0;
if (oldAnchor) {
const newAnchor = el2.querySelector<HTMLElement>(
`.turn-anchor[data-turn-id="${attrEscape(oldAnchor.id)}"]`,
);
if (newAnchor) {
delta = newAnchor.offsetTop - oldAnchor.top;
}
}
// If the page boundary split an assistant/tool turn, messagesToTurns may
// rebuild that turn with a new id. Fall back to the overall height delta so
// the viewport does not jump into the inserted history.
if (delta === 0) delta = el2.scrollHeight - oldHeight;
el2.scrollTop = oldTop + delta;
}
function attrEscape(value: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(value);
return value.replace(/["\\]/g, '\\$&');
return value.replaceAll(/["\\]/g, '\\$&');
}
function scrollToTurn(turnId: string): void {
@ -538,21 +611,58 @@ function scheduleStableFollow(maxFrames = 36): void {
stableFollowRaf = raf(tick);
}
const scrollKey = computed(() => {
type ScrollKey = {
length: number;
firstId: string;
lastId: string;
lastTextLen: number;
lastThinkingLen: number;
lastToolsLen: number;
approvalIds: string;
};
function isHistoryPrependOnly(prev: ScrollKey | undefined, next: ScrollKey): boolean {
return (
prev !== undefined &&
prev.length > 0 &&
next.length >= prev.length &&
prev.firstId !== next.firstId &&
prev.lastId === next.lastId &&
prev.lastTextLen === next.lastTextLen &&
prev.lastThinkingLen === next.lastThinkingLen &&
prev.lastToolsLen === next.lastToolsLen &&
prev.approvalIds === next.approvalIds
);
}
const scrollKey = computed<ScrollKey>(() => {
const approvalIds = (props.approvals ?? []).map((a) => a.approvalId).join(',');
const t = props.turns;
if (t.length === 0) return `0|${approvalIds}`;
const last = t.at(-1)!;
const thinkingLen = last.thinking?.length ?? 0;
const last = t.at(-1);
const thinkingLen = last?.thinking?.length ?? 0;
const toolsLen =
last.tools?.reduce(
last?.tools?.reduce(
(n, tool) => n + tool.name.length + (tool.arg?.length ?? 0) + (tool.output?.join('').length ?? 0),
0,
) ?? 0;
return `${t.length}:${last.text.length}:${thinkingLen}:${toolsLen}|${approvalIds}`;
return {
length: t.length,
firstId: t[0]?.id ?? '',
lastId: last?.id ?? '',
lastTextLen: last?.text.length ?? 0,
lastThinkingLen: thinkingLen,
lastToolsLen: toolsLen,
approvalIds,
};
});
watch(scrollKey, async () => {
watch(scrollKey, async (next, prev) => {
// Prepending older history changes this key; suppress only that exact case so
// concurrent bottom appends still raise the new-message pill.
if (historyLoadInProgress.value && isHistoryPrependOnly(prev, next)) {
updateTocViewport();
return;
}
await nextTick();
if (following.value || hasUserActionFollowLock()) scrollToBottom(false);
else showPill.value = true;
@ -638,8 +748,12 @@ let observedContent: Element | null = null;
let observedDock: HTMLElement | null = null;
let scrollRaf = 0;
let pillEligible = false;
const historyLoadInProgress = ref(false);
function scheduleFollow(allowPill: boolean): void {
// Prepending older history changes turns.length but is not new bottom content;
// suppress the "new messages" pill until the scroll position is restored.
if (historyLoadInProgress.value) return;
pillEligible = pillEligible || allowPill;
if (scrollRaf) return;
const schedule = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb: () => void) => setTimeout(cb, 16) as unknown as number;
@ -957,6 +1071,10 @@ defineExpose({ loadComposerForEdit });
:fast-moon="fastMoon"
:session-loading="sessionLoading"
:compaction="compaction"
:has-more-messages="hasMoreMessages"
:loading-more="loadingMore"
:loading-more-error="loadingMoreError"
:is-following="following"
@open-file="emit('openFile', $event)"
@open-media="emit('openMedia', $event)"
@copy-conversation-copied="handleCopyConversationCopied"
@ -964,6 +1082,7 @@ defineExpose({ loadComposerForEdit });
@open-compaction="emit('openCompaction', $event)"
@open-agent="emit('openAgent', $event)"
@edit-message="emit('editMessage', $event)"
@load-older-messages="handleLoadOlderMessages"
/>
<div v-if="activeSwarms.length > 0" class="swarm-stack">
<SwarmCard v-for="group in activeSwarms" :key="group.id" :group="group" />
@ -1035,6 +1154,7 @@ defineExpose({ loadComposerForEdit });
<button
v-if="showPill"
class="newmsg-pill"
:style="{ bottom: `${dockHeight + 12}px` }"
:aria-label="t('conversation.jumpToLatestAria')"
@click="scrollToBottom(true)"
>

View file

@ -472,6 +472,12 @@ interface ExtendedState extends KimiClientState {
sideChatSendingByAgent: Record<string, boolean>;
/** User message ids sent through BTW so they can be hidden from the main transcript. */
sideChatUserMessageIdsBySession: Record<string, string[]>;
/** True when older messages are being fetched for a session (scroll-up lazy load). */
messagesLoadingMoreBySession: Record<string, boolean>;
/** Whether the server has more older messages than currently loaded per session. */
messagesHasMoreBySession: Record<string, boolean>;
/** True when the last older-message fetch failed for a session. */
messagesLoadMoreErrorBySession: Record<string, boolean>;
}
const rawState: ExtendedState = reactive({
@ -505,6 +511,9 @@ const rawState: ExtendedState = reactive({
sideChatMessagesByAgent: {},
sideChatSendingByAgent: {},
sideChatUserMessageIdsBySession: {},
messagesLoadingMoreBySession: {},
messagesHasMoreBySession: {},
messagesLoadMoreErrorBySession: {},
});
// Models + Providers reactive state (lazy-loaded, cached)
@ -1197,6 +1206,9 @@ async function handleSessionNotFound(sessionId: string): Promise<void> {
delete rawState.gitStatusBySession[sessionId];
delete rawState.lastSeqBySession[sessionId];
delete rawState.compactionBySession[sessionId];
delete rawState.messagesLoadingMoreBySession[sessionId];
delete rawState.messagesHasMoreBySession[sessionId];
delete rawState.messagesLoadMoreErrorBySession[sessionId];
delete epochBySession[sessionId];
sessionsKnownEmpty.delete(sessionId);
@ -1232,6 +1244,10 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
...rawState.messagesBySession,
[sessionId]: snap.messages,
};
rawState.messagesHasMoreBySession = {
...rawState.messagesHasMoreBySession,
[sessionId]: snap.hasMoreMessages,
};
rawState.approvalsBySession = {
...rawState.approvalsBySession,
[sessionId]: snap.pendingApprovals,
@ -1268,6 +1284,54 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
}
}
const MESSAGES_PAGE_SIZE = 50;
async function loadOlderMessages(sessionId: string): Promise<void> {
if (rawState.messagesLoadingMoreBySession[sessionId]) return;
const current = rawState.messagesBySession[sessionId];
if (!current || current.length === 0) return;
const beforeId = current[0]!.id;
rawState.messagesLoadingMoreBySession = {
...rawState.messagesLoadingMoreBySession,
[sessionId]: true,
};
rawState.messagesLoadMoreErrorBySession = {
...rawState.messagesLoadMoreErrorBySession,
[sessionId]: false,
};
try {
const page = await getKimiWebApi().listMessages(sessionId, {
beforeId,
pageSize: MESSAGES_PAGE_SIZE,
});
// Server returns newest-first; the UI keeps messages in chronological order.
const older = [...page.items].reverse();
// Live events may have appended messages while the request was in flight;
// read the latest array so those messages are not overwritten.
const latest = rawState.messagesBySession[sessionId] ?? current;
rawState.messagesBySession = {
...rawState.messagesBySession,
[sessionId]: [...older, ...latest],
};
rawState.messagesHasMoreBySession = {
...rawState.messagesHasMoreBySession,
[sessionId]: page.hasMore,
};
} catch (err) {
rawState.messagesLoadMoreErrorBySession = {
...rawState.messagesLoadMoreErrorBySession,
[sessionId]: true,
};
pushOperationFailure('loadOlderMessages', err, { sessionId });
} finally {
rawState.messagesLoadingMoreBySession = {
...rawState.messagesLoadingMoreBySession,
[sessionId]: false,
};
}
}
async function loadTasksForSession(sessionId: string): Promise<void> {
try {
const api = getKimiWebApi();
@ -2109,6 +2173,18 @@ const connection = computed<ConnectionState>(() => rawState.connection);
const loading = computed<boolean>(() => rawState.loading);
const sessionLoading = computed<boolean>(() => rawState.sessionLoading);
const loadingMoreMessages = computed<boolean>(() => {
const sid = rawState.activeSessionId;
return sid ? rawState.messagesLoadingMoreBySession[sid] ?? false : false;
});
const hasMoreMessages = computed<boolean>(() => {
const sid = rawState.activeSessionId;
return sid ? rawState.messagesHasMoreBySession[sid] ?? false : false;
});
const loadMoreMessagesError = computed<boolean>(() => {
const sid = rawState.activeSessionId;
return sid ? rawState.messagesLoadMoreErrorBySession[sid] ?? false : false;
});
const serverVersion = computed<string>(() => rawState.serverVersion);
const permission = computed<PermissionMode>(() => rawState.permission);
@ -4302,6 +4378,9 @@ export function useKimiWebClient() {
connection,
loading,
sessionLoading,
loadingMoreMessages,
hasMoreMessages,
loadMoreMessagesError,
serverVersion,
initialized,
permission,
@ -4348,6 +4427,7 @@ export function useKimiWebClient() {
load,
selectSession,
createSession,
loadOlderMessages,
// Workspace actions
loadWorkspaces,

View file

@ -21,4 +21,6 @@ export default {
confirm: 'Confirm',
cancel: 'Cancel',
yesterday: 'Yesterday',
loadOlder: 'Load earlier messages',
loadingOlder: 'Loading earlier messages…',
} as const;

View file

@ -21,4 +21,6 @@ export default {
confirm: '确定',
cancel: '取消',
yesterday: '昨天',
loadOlder: '加载更早的消息',
loadingOlder: '正在加载更早的消息…',
} as const;

View file

@ -0,0 +1,82 @@
import { mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { nextTick } from 'vue';
import ChatPane from '../src/components/ChatPane.vue';
import type { ChatTurn } from '../src/types';
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false,
});
const turns: ChatTurn[] = [{ id: 'a1', role: 'assistant', no: 1, text: 'hello' }];
let intersectionCallback: IntersectionObserverCallback | null = null;
let realIntersectionObserver: typeof globalThis.IntersectionObserver | undefined;
class MockIntersectionObserver {
constructor(cb: IntersectionObserverCallback) {
intersectionCallback = cb;
}
observe(): void {
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
}
unobserve(): void {}
disconnect(): void {}
}
function mountChatPane(extraProps: Record<string, unknown>) {
return mount(ChatPane, {
props: {
turns,
hasMoreMessages: true,
isFollowing: false,
...extraProps,
},
global: {
plugins: [i18n],
stubs: {
Markdown: true,
ThinkingBlock: true,
ToolCall: true,
ActivityNotice: true,
AgentCard: true,
AgentGroup: true,
MoonSpinner: true,
},
},
});
}
beforeEach(() => {
intersectionCallback = null;
realIntersectionObserver = globalThis.IntersectionObserver;
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = MockIntersectionObserver;
});
afterEach(() => {
if (realIntersectionObserver) {
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = realIntersectionObserver;
} else {
delete (globalThis as unknown as { IntersectionObserver?: unknown }).IntersectionObserver;
}
});
describe('ChatPane lazy-load sentinel', () => {
it('does not auto-retry while the previous older-message load failed', async () => {
const wrapper = mountChatPane({ loadingMoreError: true });
await nextTick();
expect(wrapper.emitted('loadOlderMessages')).toBeUndefined();
await wrapper.setProps({ loadingMoreError: false });
await nextTick();
expect(wrapper.emitted('loadOlderMessages')).toHaveLength(1);
});
});

View file

@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils';
import { flushPromises, mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import { nextTick, type Component } from 'vue';
import ConversationPane from '../src/components/ConversationPane.vue';
import ChatDock from '../src/components/ChatDock.vue';
@ -38,7 +38,10 @@ function fireResize(): void {
for (const cb of resizeCallbacks) cb([], {} as ResizeObserver);
}
function mountMobilePane(extraProps: Record<string, unknown>) {
function mountMobilePane(
extraProps: Record<string, unknown>,
options: { chatPaneStub?: Component | boolean } = {},
) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@ -64,7 +67,7 @@ function mountMobilePane(extraProps: Record<string, unknown>) {
// only the heavy leaf renderers are stubbed.
stubs: {
ChatHeader: true,
ChatPane: true,
ChatPane: options.chatPaneStub ?? true,
Composer: true,
GoalStrip: true,
TasksPane: true,
@ -125,8 +128,12 @@ afterEach(() => {
/** Mount, let the initial stable-follow loop settle, then return the (geometry-
mocked) scroller pre-positioned at the bottom and "following". */
async function settledPane(geo: { scrollHeight: number; clientHeight: number }, props: Record<string, unknown> = {}) {
const wrapper = mountMobilePane({ turns: [turn(1, 'hi')], ...props });
async function settledPane(
geo: { scrollHeight: number; clientHeight: number },
props: Record<string, unknown> = {},
options: { chatPaneStub?: Component | boolean } = {},
) {
const wrapper = mountMobilePane({ turns: [turn(1, 'hi')], ...props }, options);
await nextTick();
vi.advanceTimersByTime(200); // initial scheduleStableFollow loop completes
await nextTick();
@ -157,6 +164,10 @@ function scrollUpTo(pane: HTMLElement, top: number): void {
pane.dispatchEvent(new Event('scroll'));
}
const LoadOlderChatPane = {
template: '<button class="load-older" type="button" @click="$emit(\'loadOlderMessages\')">load older</button>',
};
describe('ConversationPane follow — user scrolls up (req 2)', () => {
it('stops auto-follow and shows the pill instead of yanking the view back', async () => {
const { wrapper, pane, geo } = await settledPane({ scrollHeight: 2000, clientHeight: 500 });
@ -182,6 +193,122 @@ describe('ConversationPane follow — user scrolls up (req 2)', () => {
});
});
describe('ConversationPane follow — history prepend', () => {
async function loadOlderAndSettle(
wrapper: ReturnType<typeof mountMobilePane>,
turns: ChatTurn[],
loadOlderMessages: ReturnType<typeof vi.fn<(sessionId: string) => Promise<void>>>,
afterLoad?: () => void,
) {
loadOlderMessages.mockImplementation(async () => {
await wrapper.setProps({ turns });
afterLoad?.();
});
await wrapper.find('.load-older').trigger('click');
await flushPromises();
await nextTick();
vi.advanceTimersByTime(40);
await nextTick();
}
it('keeps the new-message pill when bottom content arrives during a prepend', async () => {
const loadOlderMessages = vi.fn<(sessionId: string) => Promise<void>>();
const { wrapper, pane } = await settledPane(
{ scrollHeight: 2000, clientHeight: 500 },
{
sessionId: 'sess_1',
hasMoreMessages: true,
loadOlderMessages,
},
{ chatPaneStub: LoadOlderChatPane },
);
scrollUpTo(pane, 300);
await nextTick();
await loadOlderAndSettle(
wrapper,
[turn(0, 'older'), turn(1, 'hi'), turn(2, 'new bottom')],
loadOlderMessages,
);
expect(pane.scrollTop).toBe(300);
expect(wrapper.find('.newmsg-pill').exists()).toBe(true);
});
it('does not show the new-message pill for a prepend-only update', async () => {
const loadOlderMessages = vi.fn<(sessionId: string) => Promise<void>>();
const { wrapper, pane } = await settledPane(
{ scrollHeight: 2000, clientHeight: 500 },
{
sessionId: 'sess_1',
hasMoreMessages: true,
loadOlderMessages,
},
{ chatPaneStub: LoadOlderChatPane },
);
scrollUpTo(pane, 300);
await nextTick();
await loadOlderAndSettle(wrapper, [turn(0, 'older'), turn(1, 'hi')], loadOlderMessages);
expect(pane.scrollTop).toBe(300);
expect(wrapper.find('.newmsg-pill').exists()).toBe(false);
});
it('does not show the new-message pill when a same-length prepend changes the first turn id', async () => {
const loadOlderMessages = vi.fn<(sessionId: string) => Promise<void>>();
const { wrapper, pane } = await settledPane(
{ scrollHeight: 2000, clientHeight: 500 },
{
sessionId: 'sess_1',
hasMoreMessages: true,
loadOlderMessages,
},
{ chatPaneStub: LoadOlderChatPane },
);
await wrapper.setProps({ turns: [turn(1, 'first'), turn(2, 'last')] });
await nextTick();
scrollUpTo(pane, 300);
await nextTick();
await loadOlderAndSettle(wrapper, [turn(0, 'merged first'), turn(2, 'last')], loadOlderMessages);
expect(pane.scrollTop).toBe(300);
expect(wrapper.find('.newmsg-pill').exists()).toBe(false);
});
it('falls back to scroll-height delta when the old anchor turn id disappears', async () => {
const loadOlderMessages = vi.fn<(sessionId: string) => Promise<void>>();
const { wrapper, pane } = await settledPane(
{ scrollHeight: 2000, clientHeight: 500 },
{
sessionId: 'sess_1',
hasMoreMessages: true,
loadOlderMessages,
},
{ chatPaneStub: LoadOlderChatPane },
);
scrollUpTo(pane, 300);
await nextTick();
await loadOlderAndSettle(
wrapper,
[turn(0, 'older'), turn(1, 'hi')],
loadOlderMessages,
() => {
mockPaneGeometry(pane, { scrollHeight: 2600, clientHeight: 500, scrollTop: 300 });
},
);
expect(pane.scrollTop).toBe(900);
});
});
describe('ConversationPane follow — user intent jumps to bottom (req 1)', () => {
it('sending a message returns to the bottom and resumes following', async () => {
const { wrapper, pane } = await settledPane({ scrollHeight: 2000, clientHeight: 500 });

View file

@ -429,6 +429,34 @@ describe('session view-model status / busy', () => {
);
expect(client.unreadBySession.value['sess_bg']).toBe(true);
});
it('loads older messages on demand and prepends them in chronological order', async () => {
const msg1 = userMessage('sess_1', 'msg_1');
const msg2 = userMessage('sess_1', 'msg_2');
const older1 = userMessage('sess_1', 'msg_0');
const { api, client } = await setup([msg1, msg2]);
api.getSessionSnapshot = vi.fn(async () => ({
asOfSeq: 0,
epoch: 'ep_test',
session: session('sess_1'),
messages: [msg1, msg2],
hasMoreMessages: true,
inFlightTurn: null,
pendingApprovals: [],
pendingQuestions: [],
}));
api.listMessages = vi.fn(async () => ({ items: [older1], hasMore: false }));
await client.selectSession('sess_1');
expect(client.hasMoreMessages.value).toBe(true);
await client.loadOlderMessages('sess_1');
expect(api.listMessages).toHaveBeenCalledWith('sess_1', { beforeId: 'msg_1', pageSize: 50 });
expect(client.turns.value.map((t) => t.id)).toEqual(['msg_0', 'msg_1', 'msg_2']);
expect(client.hasMoreMessages.value).toBe(false);
expect(client.loadingMoreMessages.value).toBe(false);
});
});
describe('unread persistence across reload', () => {