Skip to content

Commit 4dfeadf

Browse files
lin-snowclaude
andcommitted
fix(chat): 输入框多行不再遮挡历史 + 空态发丝线居中
- 输入框设最大高度(--composer-max ≈5 行),超出内部滚动;对话区底边随 --composer-h 实时让位,根除多行输入顶进历史、遮挡上方输出 - 空态时发丝线整组(输入框 + suggestions)居中,消灭上半屏大片留白; 发出首条消息后 --line-pos 由 52→25dvh 平滑下沉回 75vh 钉位 - 移除冗余的 suggestion4(年终总结)预设,suggestions 收敛为三条 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 030d5f9 commit 4dfeadf

5 files changed

Lines changed: 65 additions & 17 deletions

File tree

web/src/locales/messages/de-DE.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1016,7 +1016,6 @@
10161016
"suggestion1": "Fasse zusammen, womit ich mich in letzter Zeit beschäftigt habe",
10171017
"suggestion2": "Wie hat sich meine Stimmung in dieser Zeit verändert",
10181018
"suggestion3": "Bündle die verstreuten Ideen, die ich notiert habe",
1019-
"suggestion4": "Schreib mir eine Jahresrückblick-Zusammenfassung für dieses Jahr",
10201019
"inputPlaceholder": "Sprich mit deinen Echos",
10211020
"send": "Senden",
10221021
"clear": "Leeren",

web/src/locales/messages/en-US.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1016,7 +1016,6 @@
10161016
"suggestion1": "Summarize what I've been focusing on and thinking about lately",
10171017
"suggestion2": "How has my mood changed over this period",
10181018
"suggestion3": "Pull together the scattered ideas I've jotted down",
1019-
"suggestion4": "Write me a year-end summary for this year",
10201019
"inputPlaceholder": "Chat with your echos",
10211020
"send": "Send",
10221021
"clear": "Clear",

web/src/locales/messages/ja-JP.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1016,7 +1016,6 @@
10161016
"suggestion1": "最近わたしが注目し考えていたことをまとめて",
10171017
"suggestion2": "この期間の気分の変化は?",
10181018
"suggestion3": "書き留めた断片的なアイデアをまとめて",
1019-
"suggestion4": "今年の年末まとめを書いて",
10201019
"inputPlaceholder": "あなたの Echo と対話",
10211020
"send": "送信",
10221021
"clear": "クリア",

web/src/locales/messages/zh-CN.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1016,7 +1016,6 @@
10161016
"suggestion1": "总结我最近一段时间在关注和思考什么",
10171017
"suggestion2": "我这段时间的心情有什么变化",
10181018
"suggestion3": "把我记过的零散想法汇总一下",
1019-
"suggestion4": "帮我写一篇今年的年终总结",
10201019
"inputPlaceholder": "和你的 Echo 对话",
10211020
"send": "发送",
10221021
"clear": "清空",

web/src/views/chat/modules/TheChatBox.vue

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
22
<!-- Copyright (C) 2025-2026 lin-snow -->
33
<template>
4-
<div class="hairline-chat">
4+
<div ref="rootEl" class="hairline-chat" :class="{ 'hairline-chat--empty': isEmpty }">
55
<!-- 角落控件:无边框幽灵图标 -->
66
<button class="ghost-ctrl ghost-ctrl--back" :title="t('commonNav.backHome')" @click="goHome">
77
<Back class="ghost-ctrl__icon" />
@@ -89,6 +89,7 @@
8989

9090
<!-- 输入区:textarea 的下边框就是那条横线(钉在 75vh) -->
9191
<div
92+
ref="composerEl"
9293
class="composer"
9394
:class="{
9495
'composer--active': input.trim().length > 0 || loading,
@@ -166,8 +167,10 @@ const { openConfirm } = useBaseDialog()
166167
const input = ref<string>('')
167168
const loading = ref<boolean>(false)
168169
const messages = ref<App.Api.Chat.ChatMessage[]>([])
170+
const rootEl = ref<HTMLElement | null>(null)
169171
const scrollArea = ref<HTMLElement | null>(null)
170172
const transcriptInner = ref<HTMLElement | null>(null)
173+
const composerEl = ref<HTMLElement | null>(null)
171174
const inputEl = ref<HTMLTextAreaElement | null>(null)
172175
let abort: (() => void) | null = null
173176
@@ -190,12 +193,15 @@ const suggestions = computed<string[]>(() => [
190193
t('chatPanel.suggestion1'),
191194
t('chatPanel.suggestion2'),
192195
t('chatPanel.suggestion3'),
193-
t('chatPanel.suggestion4'),
194196
])
195197
196198
// 输入框为空且非流式时展示预设场景;开始输入或发送后淡出
197199
const showSuggestions = computed<boolean>(() => !loading.value && input.value.trim().length === 0)
198200
201+
// 尚无任何对话:发丝线整组(输入框 + suggestions)居中,告别上半屏大片空白;
202+
// 发出首条消息后 messages 非空 → 自动下沉到 75vh 钉位(CSS transition 负责平滑过渡)
203+
const isEmpty = computed<boolean>(() => messages.value.length === 0)
204+
199205
// 贴底滚动:事件驱动而非帧驱动。`pinned` 是用户「想不想贴底」的意图,只由真实滚动翻转;
200206
// 内容长高由 ResizeObserver 感知后跟随一次。彻底告别 rAF 每帧强写 scrollTop 带来的亚像素抖动。
201207
const STICK_THRESHOLD = 80
@@ -224,12 +230,22 @@ const onContentResize = () => {
224230
if (el && pinned.value) el.scrollTop = el.scrollHeight
225231
}
226232
227-
// textarea 向上生长,横线恒定钉在 75vh
233+
// 把输入框当前高度写进 --composer-h,供对话区底边实时让位。
234+
// CSS max-height(--composer-max) 已封顶,offsetHeight 即真实封顶后的高度。
235+
const syncComposerHeight = () => {
236+
const root = rootEl.value
237+
const c = composerEl.value
238+
if (root && c) root.style.setProperty('--composer-h', `${c.offsetHeight}px`)
239+
}
240+
241+
// textarea 向上生长,横线恒定钉在 75vh;封顶由 CSS max-height 负责(超出内部滚动)
228242
const autoGrow = () => {
229243
const el = inputEl.value
230244
if (!el) return
231245
el.style.height = 'auto'
232-
el.style.height = `${Math.min(el.scrollHeight, window.innerHeight * 0.28)}px`
246+
el.style.height = `${el.scrollHeight}px`
247+
syncComposerHeight()
248+
onContentResize() // 同帧贴底,杜绝输入框增高与对话区让位错开一帧的闪现
233249
}
234250
235251
const goHome = () => router.push('/')
@@ -333,10 +349,16 @@ onMounted(async () => {
333349
// 贴底引擎接线:scroll 监听更新意图,ResizeObserver 感知内容长高后跟随
334350
const area = scrollArea.value
335351
if (area) area.addEventListener('scroll', onScroll, { passive: true })
336-
if (transcriptInner.value && typeof ResizeObserver === 'function') {
337-
resizeObserver = new ResizeObserver(onContentResize)
338-
resizeObserver.observe(transcriptInner.value)
352+
if (typeof ResizeObserver === 'function') {
353+
// 一个实例观两目标:对话内容长高 → 贴底;输入框增高 → 写 --composer-h 并贴底
354+
resizeObserver = new ResizeObserver(() => {
355+
syncComposerHeight()
356+
onContentResize()
357+
})
358+
if (transcriptInner.value) resizeObserver.observe(transcriptInner.value)
359+
if (composerEl.value) resizeObserver.observe(composerEl.value)
339360
}
361+
syncComposerHeight() // 首帧兜底,避免对话区底边先用默认值再跳一下
340362
341363
try {
342364
const res = await getChatSession()
@@ -368,6 +390,16 @@ onBeforeUnmount(() => {
368390

369391
<style scoped>
370392
.hairline-chat {
393+
/* 输入框滚动前的最大高度(≈5 行),小屏再按视口收口;单一事实源,对话区让位与 textarea 封顶共用 */
394+
--composer-max: min(8.5rem, 30dvh);
395+
396+
/* 输入框当前高度,autoGrow / ResizeObserver 实时写入;首帧 1 行兜底 */
397+
--composer-h: 1.6rem;
398+
399+
/* 发丝线距视口底的距离:有对话时钉在 25dvh(即 75vh 处),
400+
空态时抬到视口中部,让输入框那组元素居中、消灭上半屏留白 */
401+
--line-pos: 25dvh;
402+
371403
position: relative;
372404
width: 100%;
373405
height: 100vh;
@@ -377,6 +409,12 @@ onBeforeUnmount(() => {
377409
color: var(--color-text-primary);
378410
}
379411
412+
/* 空态:整组上移居中。composer.bottom / understory.top 各自带 transition,
413+
首条消息发出后 --line-pos 切回 25dvh 即平滑下沉 */
414+
.hairline-chat--empty {
415+
--line-pos: 52dvh;
416+
}
417+
380418
/* ── 角落幽灵控件 ───────────────────────────── */
381419
.ghost-ctrl {
382420
position: absolute;
@@ -429,7 +467,10 @@ onBeforeUnmount(() => {
429467
/* ── 对话区(线之上,贴底向上生长) ─────────── */
430468
.transcript {
431469
position: absolute;
432-
inset: 0 0 calc(25dvh + 3rem);
470+
471+
/* 底边随输入框当前高度实时让位:输入框长多少,对话区底边抬多少,永不重叠。
472+
不加 transition——textarea 是瞬时增高,对话区须同帧锁步,过渡反而会脱拍 */
473+
inset: 0 0 calc(var(--line-pos) + var(--composer-h, 1.6rem) + 1rem);
433474
434475
/* 在横线上方再留出一段呼吸距离,避免内容黏住输入框 */
435476
display: flex;
@@ -604,14 +645,16 @@ onBeforeUnmount(() => {
604645
.composer {
605646
position: absolute;
606647
left: 50%;
607-
bottom: 25vh;
608-
bottom: 25dvh;
648+
bottom: var(--line-pos);
609649
transform: translateX(-50%);
610650
z-index: 2;
611651
width: min(42rem, calc(100% - 3rem));
612652
display: flex;
613653
align-items: flex-end;
614654
gap: 0.6rem;
655+
656+
/* 仅在空态 ↔ 有对话切换时(--line-pos 变化)平滑滑动;日常打字 bottom 不变,不受影响 */
657+
transition: bottom 0.5s cubic-bezier(0.22, 1, 0.36, 1);
615658
}
616659
617660
/* 发丝线(底色):两端淡出的渐变,避免满宽硬边显得空荡 */
@@ -676,6 +719,11 @@ onBeforeUnmount(() => {
676719
}
677720
678721
@media (prefers-reduced-motion: reduce) {
722+
.composer,
723+
.understory {
724+
transition: none;
725+
}
726+
679727
.composer::before {
680728
transition: none;
681729
}
@@ -692,7 +740,7 @@ onBeforeUnmount(() => {
692740
outline: none;
693741
background: transparent;
694742
padding: 0 0 0.7rem;
695-
max-height: 28vh;
743+
max-height: var(--composer-max);
696744
font-size: 1rem;
697745
line-height: 1.6;
698746
color: var(--color-text-primary);
@@ -765,11 +813,15 @@ onBeforeUnmount(() => {
765813
.understory {
766814
position: absolute;
767815
left: 50%;
768-
top: 75vh;
769-
top: 75dvh;
816+
817+
/* 紧贴发丝线下方:线在距底 --line-pos 处,故距顶 = 100dvh - --line-pos */
818+
top: calc(100dvh - var(--line-pos));
770819
transform: translateX(-50%);
771820
width: min(42rem, calc(100% - 3rem));
772821
padding-top: 1.5rem;
822+
823+
/* 与 composer 同步滑动 */
824+
transition: top 0.5s cubic-bezier(0.22, 1, 0.36, 1);
773825
}
774826
775827
.understory__list {

0 commit comments

Comments
 (0)