From 9592739e51aade75a19722472adc49818346a2b6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 6 Jul 2026 21:02:41 +0800 Subject: [PATCH 1/2] fix(kimi-web): keep tool components from jumping on expand or collapse Also show the scroll-to-bottom button whenever scrolled up, and render a fallback icon for tools without a dedicated glyph. --- .changeset/fix-web-collapsible-jump.md | 5 ++ apps/kimi-web/scripts/gen-icon-data.mjs | 1 + .../src/components/chat/ConversationPane.vue | 60 ++++++++++++++++++- .../src/components/chat/ToolGroup.vue | 52 +++++++++++----- apps/kimi-web/src/components/chat/ToolRow.vue | 33 +++++++--- apps/kimi-web/src/lib/icon-data.ts | 3 + apps/kimi-web/src/lib/toolMeta.ts | 3 +- 7 files changed, 131 insertions(+), 26 deletions(-) create mode 100644 .changeset/fix-web-collapsible-jump.md diff --git a/.changeset/fix-web-collapsible-jump.md b/.changeset/fix-web-collapsible-jump.md new file mode 100644 index 0000000000..f6c0b8a9a1 --- /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 d228a8d03a..ce48d09e21 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 12fde8aa22..ac12210993 100644 --- a/apps/kimi-web/src/components/chat/ConversationPane.vue +++ b/apps/kimi-web/src/components/chat/ConversationPane.vue @@ -257,6 +257,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 || @@ -464,6 +465,11 @@ function onPanesScroll(): void { if (!el) return; const top = el.scrollTop; + if (isPinned()) { + lastScrollTop = top; + return; + } + if (performance.now() - lastSmoothScroll < 100) { lastScrollTop = top; return; @@ -478,6 +484,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; @@ -603,6 +610,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; @@ -824,6 +867,7 @@ let contentObserver: MutationObserver | null = null; let resizeObserver: ResizeObserver | null = null; let observedContent: Element | null = null; let observedDock: HTMLElement | null = null; +let lastObservedClientHeight = 0; let scrollRaf = 0; let pillEligible = false; const historyLoadInProgress = ref(false); @@ -839,6 +883,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; @@ -877,6 +922,7 @@ function rebindScrollObservers(): void { ensureContentObserved(); ensureDockObserved(); } + lastObservedClientHeight = el?.clientHeight ?? 0; } function onContentMutated(): void { @@ -926,7 +972,18 @@ onMounted(() => { if (typeof ResizeObserver === 'function') { resizeObserver = new ResizeObserver(() => { updatePanesScrollbarWidth(); - scheduleFollow(false); + const el = panesRef.value; + if (!el) return; + const { clientHeight } = el; + const viewportShrank = clientHeight < lastObservedClientHeight - 1; + lastObservedClientHeight = clientHeight; + // Data-driven growth (new turns, streaming) is already chased by the + // scrollKey watcher, so a content resize must not move the viewport on its + // own. Expanding/collapsing a tool row only changes content height: the row + // header stays put while its body opens downward or collapses upward in + // place. The only resize we still follow is a shrinking viewport (e.g. the + // composer dock growing and hiding the last message). + if (viewportShrank) scheduleFollow(false); }); } rebindScrollObservers(); @@ -944,6 +1001,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 a80c7e92dd..8a30af9bea 100644 --- a/apps/kimi-web/src/components/chat/ToolGroup.vue +++ b/apps/kimi-web/src/components/chat/ToolGroup.vue @@ -1,6 +1,6 @@ @@ -131,6 +142,17 @@ function toggle(): void { transform: rotate(90deg); } .tool-group-body { + display: grid; + grid-template-rows: minmax(0, 0fr); + overflow: hidden; + transition: grid-template-rows var(--duration-base) var(--ease-out); +} +.tool-group-body.open { + grid-template-rows: minmax(0, 1fr); +} +.tool-group-body-inner { + min-height: 0; + overflow: hidden; display: flex; flex-direction: column; } diff --git a/apps/kimi-web/src/components/chat/ToolRow.vue b/apps/kimi-web/src/components/chat/ToolRow.vue index 239d1a4cb1..63d49c4c4d 100644 --- a/apps/kimi-web/src/components/chat/ToolRow.vue +++ b/apps/kimi-web/src/components/chat/ToolRow.vue @@ -1,5 +1,6 @@