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" />
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()
166167const input = ref <string >(' ' )
167168const loading = ref <boolean >(false )
168169const messages = ref <App .Api .Chat .ChatMessage []>([])
170+ const rootEl = ref <HTMLElement | null >(null )
169171const scrollArea = ref <HTMLElement | null >(null )
170172const transcriptInner = ref <HTMLElement | null >(null )
173+ const composerEl = ref <HTMLElement | null >(null )
171174const inputEl = ref <HTMLTextAreaElement | null >(null )
172175let 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// 输入框为空且非流式时展示预设场景;开始输入或发送后淡出
197199const 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 带来的亚像素抖动。
201207const 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 负责(超出内部滚动)
228242const 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
235251const 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 , 30 dvh );
395+
396+ /* 输入框当前高度,autoGrow / ResizeObserver 实时写入;首帧 1 行兜底 */
397+ --composer-h : 1.6rem ;
398+
399+ /* 发丝线距视口底的距离:有对话时钉在 25dvh(即 75vh 处),
400+ 空态时抬到视口中部,让输入框那组元素居中、消灭上半屏留白 */
401+ --line-pos : 25 dvh;
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 : 52 dvh;
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 (25 dvh + 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 : 25 dvh;
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 : 28 vh ;
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 : 75 dvh;
816+
817+ /* 紧贴发丝线下方:线在距底 --line-pos 处,故距顶 = 100dvh - --line-pos */
818+ top : calc (100 dvh - 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