mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
d86fa38e11
commit
ac5b5e4cbf
7 changed files with 134 additions and 26 deletions
5
.changeset/fix-web-collapsible-jump.md
Normal file
5
.changeset/fix-web-collapsible-jump.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
web: Fix tool components jumping the conversation when expanded or collapsed.
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue