fix(kimi-web): keep tool components from jumping on expand or collapse (#1433)

* 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.

* fix(kimi-web): preserve bottom follow during content-only resizes

Late-loading media can grow after scrollKey has run; keep chasing the
bottom on content growth, and only suppress follow during the pinned
expand/collapse window.
This commit is contained in:
liruifengv 2026-07-06 22:19:02 +08:00 committed by GitHub
parent d86fa38e11
commit ac5b5e4cbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 134 additions and 26 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Fix tool components jumping the conversation when expanded or collapsed.

View file

@ -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',

View file

@ -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);

View file

@ -1,6 +1,6 @@
<!-- apps/kimi-web/src/components/chat/ToolGroup.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, inject, nextTick, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import ToolCall from './ToolCall.vue';
import { toolStackKey, toolStackPosition } from '../chatTurnRendering';
@ -49,30 +49,41 @@ const statusLabel = computed(() => {
function toggle(): void {
open.value = !open.value;
}
const pinScroll = inject<(el: HTMLElement, ms?: number) => void>('pinScroll', () => {});
const headEl = ref<HTMLElement | null>(null);
function onHeadClick(): void {
toggle();
const el = headEl.value;
if (el) nextTick(() => pinScroll(el));
}
</script>
<template>
<div class="tool-group" :class="{ open }">
<button class="tool-group-head" type="button" :aria-expanded="open" @click="toggle">
<button class="tool-group-head" ref="headEl" type="button" :aria-expanded="open" @click="onHeadClick">
<StatusDot :status="aggregateStatus" />
<Icon class="tg-ic" name="list" size="sm" />
<span class="tg-title">{{ t('tools.group.title', count) }}</span>
<span class="tg-meta">· {{ statusLabel }}</span>
<Icon class="tg-car" name="chevron-right" size="sm" />
</button>
<div v-show="open" class="tool-group-body">
<ToolCall
v-for="(item, si) in tools"
:key="toolStackKey(item)"
:tool="item.tool"
:mobile="mobile"
:stack-position="toolStackPosition(si, tools.length)"
:tool-diff-panel="toolDiffPanel"
@open-media="emit('openMedia', $event)"
@open-file="emit('openFile', $event)"
@open-tool-diff="emit('openToolDiff', $event)"
@open-agent="emit('openAgent', $event)"
/>
<div class="tool-group-body" :class="{ open }" :inert="!open">
<div class="tool-group-body-inner">
<ToolCall
v-for="(item, si) in tools"
:key="toolStackKey(item)"
:tool="item.tool"
:mobile="mobile"
:stack-position="toolStackPosition(si, tools.length)"
:tool-diff-panel="toolDiffPanel"
@open-media="emit('openMedia', $event)"
@open-file="emit('openFile', $event)"
@open-tool-diff="emit('openToolDiff', $event)"
@open-agent="emit('openAgent', $event)"
/>
</div>
</div>
</div>
</template>
@ -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;
}

View file

@ -1,5 +1,6 @@
<!-- apps/kimi-web/src/components/chat/ToolRow.vue -->
<script setup lang="ts">
import { inject, nextTick, ref } from 'vue';
import Icon from '../ui/Icon.vue';
import Tooltip from '../ui/Tooltip.vue';
import StatusDot from '../ui/StatusDot.vue';
@ -28,7 +29,16 @@ withDefaults(
},
);
defineEmits<{ toggle: [] }>();
const emit = defineEmits<{ toggle: [] }>();
const pinScroll = inject<(el: HTMLElement, ms?: number) => void>('pinScroll', () => {});
const bhEl = ref<HTMLElement | null>(null);
function onHeadClick(): void {
emit('toggle');
const el = bhEl.value;
if (el) nextTick(() => pinScroll(el));
}
</script>
<template>
@ -43,7 +53,7 @@ defineEmits<{ toggle: [] }>();
'stack-last': stackPosition === 'last',
}"
>
<div class="bh" @click="$emit('toggle')">
<div class="bh" ref="bhEl" @click="onHeadClick">
<span v-if="icon" class="gl" v-html="icon" aria-hidden="true" />
<span class="a">{{ name }}</span>
<Tooltip :text="arg">
@ -168,19 +178,24 @@ defineEmits<{ toggle: [] }>();
color: var(--color-danger);
}
/* Expanded detail: sunken panel under the row.
Collapses/expands via a height transition; `interpolate-size: allow-keywords`
(set on :root) lets `height: auto` interpolate instead of snap. The visual
styles live on `.bb-pad` so they clip cleanly inside the 0-height clip box. */
/* Expanded detail: sunken panel under the row. Opens downward / collapses upward
via a `grid-template-rows` transition (0fr 1fr), which animates smoothly in
every modern browser unlike `height: auto`, which only interpolates in
Chromium (via `interpolate-size`) and snaps everywhere else. The inner
`.bb-pad` needs `min-height: 0` + `overflow: hidden` so the 0fr track can
collapse fully. */
.bb {
height: 0;
display: grid;
grid-template-rows: minmax(0, 0fr);
overflow: hidden;
transition: height var(--duration-base) var(--ease-out);
transition: grid-template-rows var(--duration-base) var(--ease-out);
}
.bb.open {
height: auto;
grid-template-rows: minmax(0, 1fr);
}
.bb-pad {
min-height: 0;
overflow: hidden;
padding: 0 11px 11px;
background: var(--color-surface-sunken);
border-top: 1px solid var(--color-line);

View file

@ -43,6 +43,7 @@ export type IconName =
| "code"
| "terminal"
| "pencil"
| "tool"
| "glob"
| "globe"
| "check-list"
@ -113,6 +114,7 @@ export const NAME_TO_REMIX: Record<IconName, string> = {
code: "ri:code-line",
terminal: "ri:terminal-box-line",
pencil: "ri:pencil-line",
tool: "ri:tools-line",
glob: "ri:braces-line",
globe: "ri:global-line",
"check-list": "ri:list-check",
@ -175,6 +177,7 @@ export const ICON_DATA: Record<IconName, IconData> = {
code: { body: "<path fill=\"currentColor\" d=\"m23 12l-7.071 7.071l-1.414-1.414L20.172 12l-5.657-5.657l1.414-1.414zM3.828 12l5.657 5.657l-1.414 1.414L1 12l7.071-7.071l1.414 1.414z\"/>", width: 24, height: 24 },
terminal: { body: "<path fill=\"currentColor\" d=\"M3 3h18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1m1 2v14h16V5zm8 10h6v2h-6zm-3.333-3L5.838 9.172l1.415-1.415L11.495 12l-4.242 4.243l-1.415-1.415z\"/>", width: 24, height: 24 },
pencil: { body: "<path fill=\"currentColor\" d=\"m15.728 9.576l-1.414-1.414L5 17.476v1.414h1.414zm1.414-1.414l1.414-1.414l-1.414-1.414l-1.414 1.414zm-9.9 12.728H3v-4.243L16.435 3.212a1 1 0 0 1 1.414 0l2.829 2.829a1 1 0 0 1 0 1.414z\"/>", width: 24, height: 24 },
tool: { body: "<path fill=\"currentColor\" d=\"M5.33 3.272a3.5 3.5 0 0 1 4.254 4.962l10.709 10.71l-1.414 1.414l-10.71-10.71a3.502 3.502 0 0 1-4.962-4.255L5.444 7.63a1.5 1.5 0 0 0 2.121-2.121zm10.367 1.883l3.182-1.768l1.414 1.415l-1.768 3.182l-1.768.353l-2.12 2.121l-1.415-1.414l2.121-2.121zm-6.718 8.132l1.415 1.414l-5.304 5.303a1 1 0 0 1-1.492-1.327l.078-.087z\"/>", width: 24, height: 24 },
glob: { body: "<path fill=\"currentColor\" d=\"M4 18v-3.7a1.5 1.5 0 0 0-1.5-1.5H2v-1.6h.5A1.5 1.5 0 0 0 4 9.7V6a3 3 0 0 1 3-3h1v2H7a1 1 0 0 0-1 1v4.1A2 2 0 0 1 4.626 12A2 2 0 0 1 6 13.9V18a1 1 0 0 0 1 1h1v2H7a3 3 0 0 1-3-3m16-3.7V18a3 3 0 0 1-3 3h-1v-2h1a1 1 0 0 0 1-1v-4.1a2 2 0 0 1 1.374-1.9A2 2 0 0 1 18 10.1V6a1 1 0 0 0-1-1h-1V3h1a3 3 0 0 1 3 3v3.7a1.5 1.5 0 0 0 1.5 1.5h.5v1.6h-.5a1.5 1.5 0 0 0-1.5 1.5\"/>", width: 24, height: 24 },
globe: { body: "<path fill=\"currentColor\" d=\"M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10m-2.29-2.333A17.9 17.9 0 0 1 8.027 13H4.062a8.01 8.01 0 0 0 5.648 6.667M10.03 13c.151 2.439.848 4.73 1.97 6.752A15.9 15.9 0 0 0 13.97 13zm9.908 0h-3.965a17.9 17.9 0 0 1-1.683 6.667A8.01 8.01 0 0 0 19.938 13M4.062 11h3.965A17.9 17.9 0 0 1 9.71 4.333A8.01 8.01 0 0 0 4.062 11m5.969 0h3.938A15.9 15.9 0 0 0 12 4.248A15.9 15.9 0 0 0 10.03 11m4.259-6.667A17.9 17.9 0 0 1 15.973 11h3.965a8.01 8.01 0 0 0-5.648-6.667\"/>", width: 24, height: 24 },
"check-list": { body: "<path fill=\"currentColor\" d=\"M8 4h13v2H8zm-5-.5h3v3H3zm0 7h3v3H3zm0 7h3v3H3zM8 11h13v2H8zm0 7h13v2H8z\"/>", width: 24, height: 24 },

View file

@ -99,7 +99,8 @@ export function toolGlyph(name: string): string {
const key = normalizeToolName(name);
let icon = TOOL_GLYPH[key];
if (!icon && (name ?? '').trim().toLowerCase().includes('skill')) icon = 'bolt';
return icon ? iconSvg(icon, 'sm') : '';
if (!icon) icon = 'tool';
return iconSvg(icon, 'sm');
}
// ---------------------------------------------------------------------------