diff --git a/.changeset/fix-web-collapsible-jump.md b/.changeset/fix-web-collapsible-jump.md
new file mode 100644
index 000000000..f6c0b8a9a
--- /dev/null
+++ b/.changeset/fix-web-collapsible-jump.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": patch
+---
+
+web: Fix tool components jumping the conversation when expanded or collapsed.
diff --git a/apps/kimi-web/scripts/gen-icon-data.mjs b/apps/kimi-web/scripts/gen-icon-data.mjs
index d228a8d03..ce48d09e2 100644
--- a/apps/kimi-web/scripts/gen-icon-data.mjs
+++ b/apps/kimi-web/scripts/gen-icon-data.mjs
@@ -68,6 +68,7 @@ const GROUPS = [
code: 'code-line',
terminal: 'terminal-box-line',
pencil: 'pencil-line',
+ tool: 'tools-line',
glob: 'braces-line',
globe: 'global-line',
'check-list': 'list-check',
diff --git a/apps/kimi-web/src/components/chat/ConversationPane.vue b/apps/kimi-web/src/components/chat/ConversationPane.vue
index 76a748329..f3f06d05e 100644
--- a/apps/kimi-web/src/components/chat/ConversationPane.vue
+++ b/apps/kimi-web/src/components/chat/ConversationPane.vue
@@ -235,6 +235,7 @@ function resolveAgentTaskId(toolCallId: string): string | undefined {
return undefined;
}
provide('resolveAgentTaskId', resolveAgentTaskId);
+provide('pinScroll', pinScrollFor);
const todoDoneCount = computed(() => (props.todos ?? []).filter((td) => td.status === 'done').length);
const hasDockWork = computed(() =>
bashTasks.value.length > 0 ||
@@ -442,6 +443,11 @@ function onPanesScroll(): void {
if (!el) return;
const top = el.scrollTop;
+ if (isPinned()) {
+ lastScrollTop = top;
+ return;
+ }
+
if (performance.now() - lastSmoothScroll < 100) {
lastScrollTop = top;
return;
@@ -456,6 +462,7 @@ function onPanesScroll(): void {
}
if (top < lastScrollTop - 1 && dist > 1) {
following.value = false;
+ showPill.value = true;
} else if (dist <= BOTTOM_THRESHOLD && top > lastScrollTop + 1) {
following.value = true;
showPill.value = false;
@@ -581,6 +588,42 @@ function cancelRaf(id: number): void {
else clearTimeout(id);
}
+// --- Scroll anchoring for expand/collapse interactions ----------------------
+// Toggling a tool row/group grows or shrinks its body, which would otherwise move
+// the viewport: a collapse near the bottom shrinks scrollHeight and lets the
+// browser clamp scrollTop, and the auto-follow may snap to the tail. While the
+// transition runs we pin the toggled row's viewport position and suppress the
+// auto-follow, so the row stays put and only its body opens downward / collapses
+// upward.
+let pinUntil = 0;
+let pinRaf = 0;
+let pinEl: HTMLElement | null = null;
+let pinTargetTop = 0;
+
+function isPinned(): boolean {
+ return performance.now() < pinUntil;
+}
+
+function pinScrollFor(el: HTMLElement, ms = 260): void {
+ const panes = panesRef.value;
+ if (!panes) return;
+ pinEl = el;
+ pinTargetTop = el.getBoundingClientRect().top;
+ pinUntil = performance.now() + ms;
+ if (pinRaf) return;
+ const tick = () => {
+ pinRaf = 0;
+ if (performance.now() >= pinUntil || !pinEl) {
+ pinEl = null;
+ return;
+ }
+ const delta = pinEl.getBoundingClientRect().top - pinTargetTop;
+ if (delta) panes.scrollTop += delta;
+ pinRaf = raf(tick);
+ };
+ pinRaf = raf(tick);
+}
+
function scheduleStableFollow(maxFrames = 36): void {
if (!following.value && !hasUserActionFollowLock()) return;
const token = ++stableFollowToken;
@@ -802,6 +845,8 @@ let contentObserver: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null;
let observedContent: Element | null = null;
let observedDock: HTMLElement | null = null;
+let lastObservedScrollHeight = 0;
+let lastObservedClientHeight = 0;
let scrollRaf = 0;
let pillEligible = false;
const historyLoadInProgress = ref(false);
@@ -817,6 +862,7 @@ function scheduleFollow(allowPill: boolean): void {
scrollRaf = 0;
const wantPill = pillEligible;
pillEligible = false;
+ if (isPinned()) return;
if (following.value || hasUserActionFollowLock()) scrollToBottom(false);
else if (wantPill) showPill.value = true;
}) as unknown as number;
@@ -855,6 +901,8 @@ function rebindScrollObservers(): void {
ensureContentObserved();
ensureDockObserved();
}
+ lastObservedScrollHeight = el?.scrollHeight ?? 0;
+ lastObservedClientHeight = el?.clientHeight ?? 0;
}
function onContentMutated(): void {
@@ -904,7 +952,19 @@ onMounted(() => {
if (typeof ResizeObserver === 'function') {
resizeObserver = new ResizeObserver(() => {
updatePanesScrollbarWidth();
- scheduleFollow(false);
+ const el = panesRef.value;
+ if (!el) return;
+ const { scrollHeight, clientHeight } = el;
+ const grew = scrollHeight > lastObservedScrollHeight + 1;
+ const viewportShrank = clientHeight < lastObservedClientHeight - 1;
+ lastObservedScrollHeight = scrollHeight;
+ lastObservedClientHeight = clientHeight;
+ // Follow the tail on genuine growth (new turns, streaming, or late-loading
+ // media that gain height after scrollKey has already run) or a shrinking
+ // viewport (composer dock growing and hiding the last message). While a tool
+ // row/group is being toggled (the pinned window) suppress follow entirely,
+ // so the row opens downward / collapses upward without moving the viewport.
+ if (!isPinned() && (grew || viewportShrank)) scheduleFollow(false);
});
}
rebindScrollObservers();
@@ -922,6 +982,7 @@ onUnmounted(() => {
if (resizeObserver) resizeObserver.disconnect();
if (scrollRaf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(scrollRaf);
if (stableFollowRaf) cancelRaf(stableFollowRaf);
+ if (pinRaf) cancelRaf(pinRaf);
if (abortToastTimer !== null) clearTimeout(abortToastTimer);
if (copyConversationCopiedTimer !== null) {
clearTimeout(copyConversationCopiedTimer);
diff --git a/apps/kimi-web/src/components/chat/ToolGroup.vue b/apps/kimi-web/src/components/chat/ToolGroup.vue
index a80c7e92d..8a30af9be 100644
--- a/apps/kimi-web/src/components/chat/ToolGroup.vue
+++ b/apps/kimi-web/src/components/chat/ToolGroup.vue
@@ -1,6 +1,6 @@