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 @@ @@ -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 239d1a4cb..63d49c4c4 100644 --- a/apps/kimi-web/src/components/chat/ToolRow.vue +++ b/apps/kimi-web/src/components/chat/ToolRow.vue @@ -1,5 +1,6 @@