fix(kimi-web): auto-scroll to bottom on send, session switch, and tab switch

- Scroll side-chat panel to bottom after sending and while streaming
- Reset scroll baseline on session switch to avoid stale lastScrollTop
- Reset scroll baseline when returning from files tab to chat
- Reset scroll baseline after user sends a message
- Include @moonshot-ai/kimi-code so CLI rebuilds bundle the updated web app
This commit is contained in:
qer 2026-06-16 01:42:17 +08:00
parent 94d37f9519
commit 9abc23bd99
9 changed files with 336 additions and 3 deletions

View file

@ -17,6 +17,7 @@ All other `@moonshot-ai/*` packages are treated as internal packages, including
2. **List packages that were actually changed.** Source code, build config, package metadata, and other changes that affect a package's output or behavior need a changeset entry for that package.
3. **Do not list unchanged internal packages.** For example, if `packages/node-sdk` was not changed, do not list `@moonshot-ai/kimi-code-sdk` just because another internal package changed. The SDK follows the same rule as other internal packages: list it only when it was actually changed.
4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, also list `@moonshot-ai/kimi-code`.
- **Web app (`@moonshot-ai/kimi-web`) changes always enter the CLI bundle.** `@moonshot-ai/kimi-web` is ignored by changesets (see `.changeset/config.json`), so it will not be bumped automatically. However, the CLI serves the web app from `dist-web`, so any source change in `apps/kimi-web` must list **both** `@moonshot-ai/kimi-web` and `@moonshot-ai/kimi-code` in the changeset. Otherwise `pkg.pr.new` and release builds will ship a stale web bundle.
5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump.
6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled.
@ -97,6 +98,17 @@ Only SDK source changed, and the CLI does not use it:
Clarify session status typing for internal SDK callers.
```
Web app source changed (must also bump the CLI so the bundled web bundle is rebuilt):
```markdown
---
"@moonshot-ai/kimi-web": patch
"@moonshot-ai/kimi-code": patch
---
Fix the web chat not scrolling to the bottom after sending a message.
```
## Red Flags
- You are about to write `major` without asking the user.

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-web": patch
"@moonshot-ai/kimi-code": patch
---
Fix the main chat not scrolling to the bottom when returning to the chat tab from the files tab.

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-web": patch
"@moonshot-ai/kimi-code": patch
---
Reset the scroll tracking baseline after the user sends a message to prevent stale scroll positions from breaking auto-scroll in production builds.

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-web": patch
"@moonshot-ai/kimi-code": patch
---
Fix the main chat not scrolling to the bottom when switching to a shorter session.

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-web": patch
"@moonshot-ai/kimi-code": patch
---
Fix the side-chat panel not scrolling to the bottom after sending a message or while streaming a response.

View file

@ -799,6 +799,11 @@ watch(
filesShowPreview.value = false;
selectedFile.value = null;
following.value = true;
// Reset the scroll tracking baseline so the browser clamping scroll event
// that fires when the new (usually shorter) session renders is not mistaken
// for a user upward scroll, which would flip following back off before the
// stable-follow loop can pin the view to the bottom.
lastScrollTop = 0;
await nextTick();
scheduleStableFollow();
updateTocViewport();

View file

@ -4,12 +4,12 @@
only show the messages exchanged here a focused Q&A on the side. Reuses
ChatPane for the transcript; its panel-open emits are no-ops here. -->
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import ChatPane from './ChatPane.vue';
import type { ChatTurn } from '../types';
defineProps<{
const props = defineProps<{
turns: ChatTurn[];
running: boolean;
sending: boolean;
@ -24,6 +24,7 @@ const { t } = useI18n();
const draft = ref('');
const inputRef = ref<HTMLTextAreaElement | null>(null);
const bodyRef = ref<HTMLDivElement | null>(null);
function submit(): void {
const text = draft.value.trim();
@ -32,9 +33,35 @@ function submit(): void {
draft.value = '';
void nextTick(() => {
if (inputRef.value) inputRef.value.style.height = 'auto';
scrollToBottom();
});
}
function scrollToBottom(): void {
const el = bodyRef.value;
if (!el) return;
el.scrollTop = el.scrollHeight;
}
const scrollKey = computed(() => {
const t = props.turns;
if (t.length === 0) return '0';
const last = t.at(-1)!;
const thinkingLen = last.thinking?.length ?? 0;
const toolsLen =
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}`;
});
watch(scrollKey, async () => {
if (!props.running && !props.sending) return;
await nextTick();
scrollToBottom();
});
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
@ -60,7 +87,7 @@ function autosize(): void {
</button>
</div>
<div class="sc-body">
<div ref="bodyRef" class="sc-body">
<div v-if="turns.length === 0" class="sc-empty">{{ t('sideChat.empty') }}</div>
<ChatPane
v-else

View file

@ -0,0 +1,138 @@
import { mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import ConversationPane from '../src/components/ConversationPane.vue';
import type { ConversationStatus } from '../src/types';
const status: ConversationStatus = {
model: 'kimi-test',
modelId: 'kimi-test',
ctxUsed: 0,
ctxMax: 0,
permission: 'manual',
branch: 'main',
cwd: '/repo',
isGitRepo: true,
};
function mountPane(extraProps: Record<string, unknown>) {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false,
});
return mount(ConversationPane, {
attachTo: document.body,
props: {
mobile: true,
turns: [],
tasks: [],
status,
active: 'chat',
fileReloadKey: 'sess_1',
sessionLoading: false,
running: false,
...extraProps,
},
global: {
plugins: [i18n],
stubs: {
TabBar: true,
ChatHeader: true,
ChatPane: true,
Composer: true,
GoalStrip: true,
TasksPane: true,
TodoCard: true,
Terminal: true,
SwarmCard: true,
FileTree: true,
DiffView: true,
ChangedTree: true,
FilePreview: true,
},
},
});
}
function mockPaneGeometry(
el: HTMLElement,
geometry: { scrollHeight: number; clientHeight: number; scrollTop: number },
): void {
Object.defineProperty(el, 'scrollHeight', {
configurable: true,
get: () => geometry.scrollHeight,
});
Object.defineProperty(el, 'clientHeight', {
configurable: true,
get: () => geometry.clientHeight,
});
Object.defineProperty(el, 'scrollTop', {
configurable: true,
writable: true,
value: geometry.scrollTop,
});
}
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('ConversationPane session switch scroll', () => {
it('scrolls to the bottom when switching to a shorter session', async () => {
vi.useFakeTimers();
vi.spyOn(performance, 'now').mockReturnValue(100_000);
const longTurns = Array.from({ length: 20 }, (_, i) => ({
id: `t${i}`,
role: 'user' as const,
no: i + 1,
text: `message ${i + 1}`,
}));
const wrapper = mountPane({
turns: longTurns,
fileReloadKey: 'sess-long',
});
await nextTick();
const panesEl = wrapper.find('.panes').element as HTMLElement;
mockPaneGeometry(panesEl, { scrollHeight: 2000, clientHeight: 500, scrollTop: 1500 });
// Simulate the user having scrolled the long session to the bottom.
await panesEl.dispatchEvent(new Event('scroll'));
await nextTick();
// Switch to a much shorter session. The fileReloadKey watcher resets the
// scroll baseline synchronously; dispatch the transient clamping scroll
// event right after setProps resolves but before the async watcher ticks
// (scrollKey / scheduleStableFollow) run and overwrite lastScrollTop.
await wrapper.setProps({
fileReloadKey: 'sess-short',
turns: [{ id: 't1', role: 'user' as const, no: 1, text: 'hi' }],
});
// Transient geometry: scrollHeight still large, scrollTop clamped to 0.
mockPaneGeometry(panesEl, { scrollHeight: 2000, clientHeight: 500, scrollTop: 0 });
await panesEl.dispatchEvent(new Event('scroll'));
// Now let the async watcher ticks run.
await nextTick();
// New session finally settles to short geometry.
mockPaneGeometry(panesEl, { scrollHeight: 300, clientHeight: 500, scrollTop: 0 });
await nextTick();
// Let scheduleStableFollow run its rAF ticks.
vi.advanceTimersByTime(200);
await nextTick();
expect(panesEl.scrollTop).toBe(300);
});
});

View file

@ -0,0 +1,127 @@
import { mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import SideChatPanel from '../src/components/SideChatPanel.vue';
import type { ChatTurn } from '../src/types';
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideChat: {
title: 'Side chat',
subtitle: 'Ask a follow-up',
placeholder: 'Ask a question…',
send: 'Send',
empty: 'No messages yet.',
},
thinking: { close: 'Close' },
},
},
missingWarn: false,
fallbackWarn: false,
});
function mockBodyScroll(el: HTMLElement, scrollHeight: number): void {
Object.defineProperty(el, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(el, 'scrollTop', {
configurable: true,
writable: true,
value: 0,
});
}
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});
describe('SideChatPanel', () => {
it('scrolls to bottom when Enter sends a message', async () => {
const wrapper = mount(SideChatPanel, {
props: { turns: [], running: false, sending: false },
global: {
plugins: [i18n],
stubs: { ChatPane: true },
},
attachTo: document.body,
});
await nextTick();
const bodyEl = wrapper.find('.sc-body').element as HTMLElement;
mockBodyScroll(bodyEl, 500);
const textarea = wrapper.get('textarea');
await textarea.setValue('hello');
await textarea.trigger('keydown', { key: 'Enter', isComposing: false });
await nextTick();
expect(bodyEl.scrollTop).toBe(500);
expect(wrapper.emitted('send')).toEqual([['hello']]);
});
it('keeps scrolling to bottom while a response streams in', async () => {
const turns: ChatTurn[] = [
{ id: 'u1', role: 'user', no: 1, text: 'hello' },
{ id: 'a1', role: 'assistant', no: 2, text: '' },
];
const wrapper = mount(SideChatPanel, {
props: { turns, running: true, sending: false },
global: {
plugins: [i18n],
stubs: { ChatPane: true },
},
attachTo: document.body,
});
await nextTick();
const bodyEl = wrapper.find('.sc-body').element as HTMLElement;
mockBodyScroll(bodyEl, 800);
await wrapper.setProps({
turns: [
{ id: 'u1', role: 'user', no: 1, text: 'hello' },
{ id: 'a1', role: 'assistant', no: 2, text: 'first line' },
],
});
await nextTick();
expect(bodyEl.scrollTop).toBe(800);
});
it('does not auto-scroll while the panel is idle', async () => {
const turns: ChatTurn[] = [
{ id: 'u1', role: 'user', no: 1, text: 'hello' },
];
const wrapper = mount(SideChatPanel, {
props: { turns, running: false, sending: false },
global: {
plugins: [i18n],
stubs: { ChatPane: true },
},
attachTo: document.body,
});
await nextTick();
const bodyEl = wrapper.find('.sc-body').element as HTMLElement;
mockBodyScroll(bodyEl, 300);
bodyEl.scrollTop = 50;
await wrapper.setProps({
turns: [
{ id: 'u1', role: 'user', no: 1, text: 'hello' },
{ id: 'u2', role: 'user', no: 2, text: 'later' },
],
});
await nextTick();
expect(bodyEl.scrollTop).toBe(50);
});
});