mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
495fe8c674
commit
d7ec05686a
10 changed files with 620 additions and 19 deletions
5
.changeset/web-session-lazy-loading.md
Normal file
5
.changeset/web-session-lazy-loading.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -21,4 +21,6 @@ export default {
|
|||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
yesterday: 'Yesterday',
|
||||
loadOlder: 'Load earlier messages',
|
||||
loadingOlder: 'Loading earlier messages…',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -21,4 +21,6 @@ export default {
|
|||
confirm: '确定',
|
||||
cancel: '取消',
|
||||
yesterday: '昨天',
|
||||
loadOlder: '加载更早的消息',
|
||||
loadingOlder: '正在加载更早的消息…',
|
||||
} as const;
|
||||
|
|
|
|||
82
apps/kimi-web/test/chatpane-lazy-load.test.ts
Normal file
82
apps/kimi-web/test/chatpane-lazy-load.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue