mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
94d37f9519
commit
9abc23bd99
9 changed files with 336 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
6
.changeset/files-to-chat-auto-scroll.md
Normal file
6
.changeset/files-to-chat-auto-scroll.md
Normal 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.
|
||||
6
.changeset/send-message-auto-scroll.md
Normal file
6
.changeset/send-message-auto-scroll.md
Normal 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.
|
||||
6
.changeset/session-switch-auto-scroll.md
Normal file
6
.changeset/session-switch-auto-scroll.md
Normal 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.
|
||||
6
.changeset/side-chat-auto-scroll.md
Normal file
6
.changeset/side-chat-auto-scroll.md
Normal 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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
138
apps/kimi-web/test/conversation-pane-scroll.test.ts
Normal file
138
apps/kimi-web/test/conversation-pane-scroll.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
127
apps/kimi-web/test/side-chat-panel.test.ts
Normal file
127
apps/kimi-web/test/side-chat-panel.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue