Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-web-collapsible-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix tool components jumping the conversation when expanded or collapsed.
1 change: 1 addition & 0 deletions apps/kimi-web/scripts/gen-icon-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
63 changes: 62 additions & 1 deletion apps/kimi-web/src/components/chat/ConversationPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Comment thread
liruifengv marked this conversation as resolved.
pinRaf = raf(tick);
};
pinRaf = raf(tick);
}

function scheduleStableFollow(maxFrames = 36): void {
if (!following.value && !hasUserActionFollowLock()) return;
const token = ++stableFollowToken;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -855,6 +901,8 @@ function rebindScrollObservers(): void {
ensureContentObserved();
ensureDockObserved();
}
lastObservedScrollHeight = el?.scrollHeight ?? 0;
lastObservedClientHeight = el?.clientHeight ?? 0;
}

function onContentMutated(): void {
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down
52 changes: 37 additions & 15 deletions apps/kimi-web/src/components/chat/ToolGroup.vue
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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;
}
Expand Down
33 changes: 24 additions & 9 deletions apps/kimi-web/src/components/chat/ToolRow.vue
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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>
Expand All @@ -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">
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-web/src/lib/icon-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type IconName =
| "code"
| "terminal"
| "pencil"
| "tool"
| "glob"
| "globe"
| "check-list"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 },
Expand Down
3 changes: 2 additions & 1 deletion apps/kimi-web/src/lib/toolMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

// ---------------------------------------------------------------------------
Expand Down
Loading