fix(frontend): 修复推理滑块动画卡顿、快捷窗口待办跨对话串数据、输入框高度回退

- EffortSlider: 圆钮/填充改走 transform + clip-path 合成器属性,rAF 直写 DOM 绕过 Vue 响应式,消除每帧重渲染与 reflow
- useLegacySocket: todo_updated 全局广播按 conversation_id 过滤,不再串入子智能体待办;移除用事件 conv id 覆盖当前对话 id 的危险逻辑
- watchers: 修复 currentConversationId 重复键互相覆盖导致清空逻辑从未注册的问题,合并为单个 watcher(/new 页面不再残留待办)
- InputComposer: 输入框最大高度 6 行 -> 10 行
This commit is contained in:
JOJO 2026-07-22 11:19:09 +08:00
parent 77a271fa23
commit 3ea8d9ad00
4 changed files with 82 additions and 55 deletions

View File

@ -8,23 +8,6 @@ export const watchers = {
multiAgentMode(newValue) {
useConversationStore().$patch({ multiAgentMode: !!newValue });
},
currentConversationId(newId, oldId) {
if (newId === oldId) {
return;
}
// 同步到 conversationStoreQuickDock 等组件监听 store 侧的 id
useConversationStore().setCurrentConversationId(newId || null);
const quickDock = useQuickDockStore();
// 关闭详情/预览/菜单等瞬态(必须立即)
quickDock.resetTransient();
if (!newId) {
// /new 等无对话场景:不会有 bootstrap 回填,立即清空让列折叠
quickDock.setEditedFiles([]);
useFileStore().setTodoList(null);
}
// 切到另一对话:不清列表 —— 旧内容短暂保留,由 bootstrap/fetch 回填自然覆盖;
// 新对话无内容时窗口在回填后才消失,避免列「先收起再展开」的闪烁。
},
inputMessage() {
this.autoResizeInput();
if (typeof this.scheduleComposerDraftPersist === 'function') {
@ -85,6 +68,23 @@ export const watchers = {
currentConversationId: {
immediate: false,
handler(newValue, oldValue) {
// 【合并自原同名函数 watcher】对象字面量中两个 currentConversationId
// 键会互相覆盖(后者覆盖前者),原函数版 watcher 从未生效,导致
// /new 页面残留上一对话的快捷窗口待办。逻辑合并到此处统一执行。
if (newValue !== oldValue) {
// 同步到 conversationStoreQuickDock 等组件监听 store 侧的 id
useConversationStore().setCurrentConversationId(newValue || null);
const quickDock = useQuickDockStore();
// 关闭详情/预览/菜单等瞬态(必须立即)
quickDock.resetTransient();
if (!newValue) {
// /new 等无对话场景:不会有 bootstrap 回填,立即清空让列折叠
quickDock.setEditedFiles([]);
useFileStore().setTodoList(null);
}
// 切到另一对话:不清列表 —— 旧内容短暂保留,由 bootstrap/fetch 回填自然覆盖;
// 新对话无内容时窗口在回填后才消失,避免列「先收起再展开」的闪烁。
}
debugLog('currentConversationId 变化', {
oldValue,
newValue,

View File

@ -15,21 +15,27 @@ const emit = defineEmits<{
}>();
const trackRef = ref<HTMLElement | null>(null);
const knobRef = ref<HTMLElement | null>(null);
const fillRef = ref<HTMLElement | null>(null);
// v-for LEVELS
const dotEls: (HTMLElement | null)[] = [];
const setDotRef = (el: Element | null, i: number) => {
dotEls[i] = (el as HTMLElement | null) ?? null;
};
const trackWidth = ref(0);
//
const levelIndex = ref(2);
// /
const knobX = ref(0);
// /JS rAF /
// covered
// /
//
const knobXVisual = ref(0);
const activeLevel = ref(2);
const dragging = ref(false);
const liveDrag = ref(false);
let downX = 0;
let animFrame = 0;
// /
// applyVisualX DOMknob transformfill clip-path
// layout/paint class Vue
// ref + left/width rAF Vue reflow
//
let visualX = 0;
const isDefault = computed(() => props.modelValue === null);
@ -55,6 +61,29 @@ const KNOB_ANIM_MS = 200;
// CSS ease easeInOutQuad
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
// DOMknob translatefill
// clip-path inset round 17px covered
//
// trackWidth=0 退
const applyVisualX = (x: number) => {
const measured = trackWidth.value > EDGE * 2;
const expr = measured ? `${x}px` : posPct(levelIndex.value);
const knob = knobRef.value;
if (knob) {
knob.style.transform = `translate(calc(${expr} - 50%), -50%)`;
}
const fill = fillRef.value;
if (fill) {
fill.style.clipPath = `inset(0 calc(100% - (${expr})) 0 0 round 17px)`;
}
for (let i = 0; i < LEVELS.length; i++) {
const el = dotEls[i];
if (!el) continue;
const covered = measured ? posPx(i) <= x : i <= levelIndex.value;
el.classList.toggle('covered', covered);
}
};
const cancelKnobAnim = () => {
if (animFrame) {
cancelAnimationFrame(animFrame);
@ -64,18 +93,20 @@ const cancelKnobAnim = () => {
const setKnobInstant = (x: number) => {
cancelKnobAnim();
knobXVisual.value = x;
visualX = x;
applyVisualX(x);
};
//
const animateKnobTo = (target: number) => {
const start = knobXVisual.value;
const start = visualX;
if (start === target) return;
cancelKnobAnim();
const t0 = performance.now();
const step = (now: number) => {
const p = Math.min(1, (now - t0) / KNOB_ANIM_MS);
knobXVisual.value = start + (target - start) * easeInOut(p);
visualX = start + (target - start) * easeInOut(p);
applyVisualX(visualX);
if (p < 1) {
animFrame = requestAnimationFrame(step);
} else {
@ -88,7 +119,6 @@ const animateKnobTo = (target: number) => {
// animate=true/false/resize/
const syncToLevel = (animate = false) => {
const target = posPx(levelIndex.value);
knobX.value = target;
activeLevel.value = levelIndex.value;
if (animate && trackWidth.value > EDGE * 2) {
animateKnobTo(target);
@ -97,18 +127,6 @@ const syncToLevel = (animate = false) => {
}
};
// trackWidth=0
// /退
// covered 退
const knobLeftStyle = computed(() =>
trackWidth.value > EDGE * 2 ? `${knobXVisual.value}px` : posPct(levelIndex.value)
);
const fillWidthStyle = computed(() =>
trackWidth.value > EDGE * 2 ? `${knobXVisual.value}px` : posPct(levelIndex.value)
);
const dotCovered = (i: number) =>
trackWidth.value > EDGE * 2 ? posPx(i) <= knobXVisual.value : i <= levelIndex.value;
const commitLevel = () => {
emit('update:modelValue', LEVELS[levelIndex.value]);
};
@ -131,8 +149,8 @@ const onPointerMove = (e: PointerEvent) => {
//
const x = clampEventX(e.clientX);
cancelKnobAnim();
knobX.value = x;
knobXVisual.value = x;
visualX = x;
applyVisualX(x);
activeLevel.value = pxToLevel(x);
}
};
@ -224,7 +242,7 @@ onBeforeUnmount(() => {
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
<div class="fill-stack" :style="{ width: fillWidthStyle }">
<div ref="fillRef" class="fill-stack">
<div
v-for="(_, i) in LEVELS"
:key="`lv-${i}`"
@ -236,10 +254,10 @@ onBeforeUnmount(() => {
v-for="(_, i) in LEVELS"
:key="`dot-${i}`"
class="dot"
:class="{ covered: dotCovered(i) }"
:ref="(el) => setDotRef(el as Element | null, i)"
:style="{ left: posPct(i) }"
></div>
<div class="knob" :style="{ left: knobLeftStyle }"></div>
<div ref="knobRef" class="knob"></div>
</div>
<div class="level-labels">
<span
@ -373,13 +391,14 @@ onBeforeUnmount(() => {
touch-action: none;
}
/* 填充5 层叠放,当前档淡入、其余淡出 —— 任何颜色切换都是平滑 crossfade */
/* 5 crossfade
宽度恒 100%可见宽度由 JS 写入 clip-path 控制合成器属性 reflow */
.fill-stack {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 23px;
width: 100%;
border-radius: 17px;
overflow: hidden;
}
@ -458,8 +477,8 @@ onBeforeUnmount(() => {
}
}
/* JS rAF script/
拖动连续跟随圆点变色时机与圆钮视觉位置严格同步 不用 CSS transition */
/* JS DOM script applyVisualX
knob transformfill clip-path均为合成器属性滑动零 reflow */
/* 档位圆点 */
.dot {
@ -477,9 +496,10 @@ onBeforeUnmount(() => {
background: var(--effort-dot-covered);
}
/* 拖柄 */
/* 拖柄left 恒 0水平位置由 JS 写入 transform translate */
.knob {
position: absolute;
left: 0;
top: 50%;
width: 46px;
height: 46px;

View File

@ -2590,7 +2590,8 @@ const adjustTextareaSize = () => {
target.style.height = 'auto';
const computedStyle = window.getComputedStyle(target);
const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
const maxHeight = lineHeight * 6;
// 10
const maxHeight = lineHeight * 10;
const targetHeight = Math.min(target.scrollHeight, maxHeight);
const lines = Math.max(1, Math.round(targetHeight / lineHeight));
const multiline = targetHeight > lineHeight * 1.4;

View File

@ -727,11 +727,17 @@ export async function initializeLegacySocket(ctx: any) {
ctx.socket.on('todo_updated', (data) => {
socketLog('收到todo更新事件:', data);
// 初始化期间不修改 currentConversationId避免与 bootstrapRoute 冲突
if (ctx.initialRouteResolved && data && data.conversation_id) {
ctx.currentConversationId = data.conversation_id;
// todo_updated 是全局广播:子智能体/其他对话的待办也会推到这里,
// 必须只响应当前对话,否则快捷窗口会串出子智能体的待办。
// 也不能用事件里的 conversation_id 覆盖当前对话 id —— 那会把
// 子智能体的对话 id 安到当前会话上(对话 id 同步走 conversation_resolved
if (!data || !data.conversation_id) {
return;
}
ctx.fileSetTodoList((data && data.todo_list) || null);
if (data.conversation_id !== ctx.currentConversationId) {
return;
}
ctx.fileSetTodoList(data.todo_list || null);
});
// 快捷窗口:本次对话编辑/创建文件记录更新