refactor(web): extract ConversationToc from ConversationPane (#1010)

Move the beta conversation outline (proportional bubbles, viewport
indicator, hover tooltip) into a dedicated ConversationToc component.
The child owns the nav markup, the tooltip hover state, and its own
visibility (mobile / session-loading / single-turn), while the metric
derivation and scroll-driven viewport/active-turn tracking stay in the
pane because they are coupled to the scroll container.

ConversationPane.vue: 1613 -> 1422 lines. No behavior change.
This commit is contained in:
qer 2026-06-23 15:51:49 +08:00 committed by GitHub
parent e47de610e4
commit a2650f85d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 250 additions and 202 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Extract the beta conversation outline (table of contents) into its own component.

View file

@ -11,6 +11,7 @@ import ChatHeader from './ChatHeader.vue';
import Composer from './Composer.vue';
import SwarmCard from './SwarmCard.vue';
import ChatDock from './ChatDock.vue';
import ConversationToc, { type ConversationTocItem } from './ConversationToc.vue';
import { getVisibleWorkspaces } from '../lib/workspacePicker';
import { safeRemove, STORAGE_KEYS } from '../lib/storage';
@ -235,13 +236,6 @@ watch(hasDockWork, (hasWork) => {
if (!hasWork) closeDockPanel();
});
interface ConversationTocItem {
id: string;
role: ChatTurn['role'];
no: number;
title: string;
}
function tocTitle(turn: ChatTurn): string {
if (turn.role === 'compaction') return t('conversation.compactedPlain');
if (turn.role === 'user') {
@ -304,11 +298,6 @@ const tocTotalHeight = computed(() =>
const activeTurnId = ref<string | null>(null);
const tocViewport = ref<{ top: number; height: number } | null>(null);
const tooltip = ref<{ visible: boolean; text: string; top: number }>({
visible: false,
text: '',
top: 0,
});
function updateTocViewport(): void {
const pane = panesRef.value;
@ -341,22 +330,6 @@ function updateTocViewport(): void {
};
}
function showTooltip(text: string, event: MouseEvent): void {
const target = event.currentTarget as HTMLElement | null;
if (!target) return;
tooltip.value = { visible: true, text, top: target.offsetTop };
}
function hideTooltip(): void {
tooltip.value.visible = false;
}
const showConversationToc = computed(() =>
!props.mobile &&
!props.sessionLoading &&
conversationTocItems.value.length > 1,
);
// The first pending question (if any)
const pendingQuestion = computed<UIQuestion | undefined>(() =>
props.questions && props.questions.length > 0 ? props.questions[0] : undefined,
@ -905,42 +878,16 @@ defineExpose({ loadComposerForEdit });
/>
<!-- Beta conversation outline: right edge, proportional bubbles, viewport indicator, hover tooltip. -->
<nav
v-if="showConversationToc && betaToc"
class="conversation-toc"
:aria-label="t('conversation.toc')"
>
<div class="toc-track">
<button
v-for="(item, index) in conversationTocItems"
:key="item.id"
type="button"
class="toc-bubble"
:class="[item.role, { active: activeTurnId === item.id }]"
:style="{ height: tocMetrics[index]?.height + 'px' }"
:aria-label="`#${item.no} ${item.title}`"
@mouseenter="(e: MouseEvent) => showTooltip(item.title, e)"
@mouseleave="hideTooltip"
@click="scrollToTurn(item.id)"
>
<span class="toc-no">{{ item.no }}</span>
</button>
<div
v-if="tocViewport"
class="toc-viewport"
:style="{ top: tocViewport.top + 'px', height: tocViewport.height + 'px' }"
/>
</div>
<Transition name="toc-tip">
<div
v-show="tooltip.visible"
class="toc-tooltip"
:style="{ top: tooltip.top + 'px' }"
>
{{ tooltip.text }}
</div>
</Transition>
</nav>
<ConversationToc
v-if="betaToc"
:items="conversationTocItems"
:metrics="tocMetrics"
:active-turn-id="activeTurnId"
:viewport="tocViewport"
:mobile="mobile"
:session-loading="sessionLoading"
@select="scrollToTurn"
/>
<div class="chat-layout">
<div
@ -1245,144 +1192,6 @@ defineExpose({ loadComposerForEdit });
min-width: 0;
}
}
.conversation-toc {
position: absolute;
z-index: 8;
display: flex;
flex-direction: column;
padding: 0;
top: 86px;
bottom: auto;
left: calc(50% + (var(--read-max) / 2) + 8px);
width: 46px;
max-height: calc(100% - 86px - 130px);
opacity: 0.45;
transition: opacity 0.18s ease;
}
.conversation-toc:hover {
opacity: 1;
}
.toc-track {
flex: none;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
padding: 6px 4px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
max-height: 100%;
position: relative;
}
.toc-track::-webkit-scrollbar {
display: none;
}
.toc-bubble {
appearance: none;
position: relative;
flex-shrink: 0;
border: 0;
padding: 0;
width: 34px;
border-radius: 8px;
background: transparent;
cursor: pointer;
opacity: 0.85;
transition: opacity 0.14s ease, transform 0.14s ease, box-shadow 0.14s ease;
}
.toc-bubble.active {
opacity: 1;
}
.toc-bubble:hover,
.toc-bubble:focus-visible {
opacity: 1;
transform: translateX(2px) scale(1.05);
outline: none;
}
.toc-bubble.user {
background: var(--blue);
box-shadow: none;
}
.toc-bubble.assistant {
background: var(--panel2);
box-shadow: inset 0 0 0 1px var(--line);
}
.toc-bubble.compaction {
height: 10px;
background: transparent;
box-shadow: inset 0 0 0 1px var(--faint);
border-radius: 999px;
}
.toc-bubble.active::after {
content: '';
position: absolute;
inset: -2px;
border: 2px solid var(--blue);
border-radius: 10px;
pointer-events: none;
opacity: 0.35;
}
.toc-no {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.toc-viewport {
position: absolute;
left: 0;
right: 0;
background: color-mix(in srgb, var(--blue) 10%, transparent);
pointer-events: none;
border-radius: 4px;
z-index: 0;
}
.toc-tooltip {
position: absolute;
right: calc(100% + 8px);
top: 0;
z-index: 20;
max-width: 240px;
padding: 6px 10px;
background: var(--bg);
color: var(--ink);
border: 1px solid var(--line);
border-radius: 8px;
font-size: var(--ui-font-size-xs);
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
}
.toc-tooltip::before {
content: '';
position: absolute;
left: auto;
right: -5px;
top: 10px;
border-width: 5px 0 5px 5px;
border-style: solid;
border-color: transparent transparent transparent var(--bg);
}
.toc-tip-enter-active,
.toc-tip-leave-active {
transition: opacity 0.12s ease, transform 0.12s ease;
}
.toc-tip-enter-from,
.toc-tip-leave-to {
opacity: 0;
transform: translateX(4px);
}
@container (max-width: 920px) {
.conversation-toc {
display: none;
}
}
.swarm-stack {
padding: 0 18px 16px;
}

View file

@ -0,0 +1,234 @@
<!-- apps/kimi-web/src/components/ConversationToc.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ChatTurn } from '../types';
export interface ConversationTocItem {
id: string;
role: ChatTurn['role'];
no: number;
title: string;
}
const props = defineProps<{
items: ConversationTocItem[];
/** Proportional bubble heights, parallel to `items`. */
metrics: { id: string; height: number }[];
/** Turn currently closest to the viewport middle. */
activeTurnId: string | null;
/** Viewport indicator position/size within the track. */
viewport: { top: number; height: number } | null;
mobile?: boolean;
sessionLoading?: boolean;
}>();
const emit = defineEmits<{
select: [turnId: string];
}>();
const { t } = useI18n();
// The outline is only useful once there is something to navigate, and it never
// shows on mobile or while the session is still loading.
const visible = computed(
() => !props.mobile && !props.sessionLoading && props.items.length > 1,
);
const tooltip = ref<{ visible: boolean; text: string; top: number }>({
visible: false,
text: '',
top: 0,
});
function showTooltip(text: string, event: MouseEvent): void {
const target = event.currentTarget as HTMLElement | null;
if (!target) return;
tooltip.value = { visible: true, text, top: target.offsetTop };
}
function hideTooltip(): void {
tooltip.value.visible = false;
}
</script>
<template>
<!-- Beta conversation outline: right edge, proportional bubbles, viewport indicator, hover tooltip. -->
<nav
v-if="visible"
class="conversation-toc"
:aria-label="t('conversation.toc')"
>
<div class="toc-track">
<button
v-for="(item, index) in items"
:key="item.id"
type="button"
class="toc-bubble"
:class="[item.role, { active: activeTurnId === item.id }]"
:style="{ height: metrics[index]?.height + 'px' }"
:aria-label="`#${item.no} ${item.title}`"
@mouseenter="(e: MouseEvent) => showTooltip(item.title, e)"
@mouseleave="hideTooltip"
@click="emit('select', item.id)"
>
<span class="toc-no">{{ item.no }}</span>
</button>
<div
v-if="viewport"
class="toc-viewport"
:style="{ top: viewport.top + 'px', height: viewport.height + 'px' }"
/>
</div>
<Transition name="toc-tip">
<div
v-show="tooltip.visible"
class="toc-tooltip"
:style="{ top: tooltip.top + 'px' }"
>
{{ tooltip.text }}
</div>
</Transition>
</nav>
</template>
<style scoped>
.conversation-toc {
position: absolute;
z-index: 8;
display: flex;
flex-direction: column;
padding: 0;
top: 86px;
bottom: auto;
left: calc(50% + (var(--read-max) / 2) + 8px);
width: 46px;
max-height: calc(100% - 86px - 130px);
opacity: 0.45;
transition: opacity 0.18s ease;
}
.conversation-toc:hover {
opacity: 1;
}
.toc-track {
flex: none;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
padding: 6px 4px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
max-height: 100%;
position: relative;
}
.toc-track::-webkit-scrollbar {
display: none;
}
.toc-bubble {
appearance: none;
position: relative;
flex-shrink: 0;
border: 0;
padding: 0;
width: 34px;
border-radius: 8px;
background: transparent;
cursor: pointer;
opacity: 0.85;
transition: opacity 0.14s ease, transform 0.14s ease, box-shadow 0.14s ease;
}
.toc-bubble.active {
opacity: 1;
}
.toc-bubble:hover,
.toc-bubble:focus-visible {
opacity: 1;
transform: translateX(2px) scale(1.05);
outline: none;
}
.toc-bubble.user {
background: var(--blue);
box-shadow: none;
}
.toc-bubble.assistant {
background: var(--panel2);
box-shadow: inset 0 0 0 1px var(--line);
}
.toc-bubble.compaction {
height: 10px;
background: transparent;
box-shadow: inset 0 0 0 1px var(--faint);
border-radius: 999px;
}
.toc-bubble.active::after {
content: '';
position: absolute;
inset: -2px;
border: 2px solid var(--blue);
border-radius: 10px;
pointer-events: none;
opacity: 0.35;
}
.toc-no {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.toc-viewport {
position: absolute;
left: 0;
right: 0;
background: color-mix(in srgb, var(--blue) 10%, transparent);
pointer-events: none;
border-radius: 4px;
z-index: 0;
}
.toc-tooltip {
position: absolute;
right: calc(100% + 8px);
top: 0;
z-index: 20;
max-width: 240px;
padding: 6px 10px;
background: var(--bg);
color: var(--ink);
border: 1px solid var(--line);
border-radius: 8px;
font-size: var(--ui-font-size-xs);
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
}
.toc-tooltip::before {
content: '';
position: absolute;
left: auto;
right: -5px;
top: 10px;
border-width: 5px 0 5px 5px;
border-style: solid;
border-color: transparent transparent transparent var(--bg);
}
.toc-tip-enter-active,
.toc-tip-leave-active {
transition: opacity 0.12s ease, transform 0.12s ease;
}
.toc-tip-enter-from,
.toc-tip-leave-to {
opacity: 0;
transform: translateX(4px);
}
@container (max-width: 920px) {
.conversation-toc {
display: none;
}
}
</style>