@@ -96,6 +96,7 @@ const COMPACT_SPEECH_FALLBACK_REVEAL_DELAY_MS = 700;
9696const SPEECH_PLAYBACK_STATE_STORAGE_KEY = 'neko_speech_playback_state' ;
9797const SPEECH_PLAYBACK_CHANNEL_NAME = 'neko_speech_playback_channel' ;
9898const COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY = 'neko.reactChatWindow.compactExportHistoryOpen' ;
99+ const COMPACT_HISTORY_HEIGHT_STORAGE_KEY = 'neko.reactChatWindow.compactHistorySlotHeight' ;
99100export const COMPACT_EXPORT_HISTORY_VISIBILITY_ANIMATION_MS = 560 ;
100101const COMPACT_INPUT_TOOL_WHEEL_ITEM_COUNT = 7 ;
101102const COMPACT_INPUT_TOOL_WHEEL_DRAG_THRESHOLD = 22 ;
@@ -130,6 +131,17 @@ const COMPACT_SURFACE_RESIZE_MOBILE_MIN_WIDTH = 280;
130131const COMPACT_SURFACE_RESIZE_MAX_WIDTH = 720 ;
131132const COMPACT_SURFACE_RESIZE_VIEWPORT_GUTTER = 32 ;
132133const COMPACT_SURFACE_RESIZE_MOBILE_VIEWPORT_GUTTER = 16 ;
134+ // compact 历史堆砌区(CompactExportHistoryPanel)顶部 resize bar 的高度上限钳位参数。
135+ // 下限压到 ~1-2 个气泡以便节约屏幕;上限对齐 anchor 的 max-height(width*1.46 / 78% 视口),
136+ // 避免拖超 anchor 二次截断产生「拖了没反应」的死区。默认(未拖动)公式仍是 width*1.18 / 63%。
137+ const COMPACT_HISTORY_SLOT_MIN_HEIGHT = 120 ;
138+ const COMPACT_HISTORY_SLOT_MAX_WIDTH_RATIO = 1.46 ;
139+ const COMPACT_HISTORY_SLOT_MAX_VIEWPORT_RATIO = 0.78 ;
140+ const COMPACT_HISTORY_SLOT_DEFAULT_WIDTH_RATIO = 1.18 ;
141+ const COMPACT_HISTORY_SLOT_DEFAULT_VIEWPORT_RATIO = 0.63 ;
142+ // scroll 区上方的 bar(12px+margin) 与下方 controls(展开块 ≤44px) 的固定 chrome;
143+ // 从 anchor max-height 里扣掉,避免拖到上限时 scroll 吃满 anchor、controls 溢出被裁成非交互。
144+ const COMPACT_HISTORY_SLOT_CHROME_RESERVE = 72 ;
133145const COMPACT_CHOICE_PLACEMENT_HYSTERESIS = 24 ;
134146const COMPOSER_OPTION_MARQUEE_MIN_DISTANCE = 6 ;
135147const COMPOSER_OPTION_MARQUEE_MIN_DURATION_MS = 1400 ;
@@ -152,6 +164,15 @@ type CompactSurfaceResizeState = {
152164 captureTarget : Element | null ;
153165} ;
154166
167+ type CompactHistoryResizeState = {
168+ pointerId : number ;
169+ startPointerY : number ;
170+ startHeight : number ;
171+ lastHeight : number ;
172+ moved : boolean ;
173+ captureTarget : Element | null ;
174+ } ;
175+
155176function createCompactHistoryDropRequestId ( ) {
156177 return `compact-history-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ;
157178}
@@ -373,6 +394,53 @@ function persistCompactExportHistoryOpen(open: boolean) {
373394 }
374395}
375396
397+ function readPersistedCompactHistorySlotHeight ( ) : number | null {
398+ if ( typeof window === 'undefined' ) return null ;
399+ try {
400+ const persisted = window . localStorage ?. getItem ( COMPACT_HISTORY_HEIGHT_STORAGE_KEY ) ;
401+ if ( persisted === null || persisted === undefined ) return null ;
402+ const value = Number ( persisted ) ;
403+ return Number . isFinite ( value ) && value > 0 ? value : null ;
404+ } catch {
405+ return null ;
406+ }
407+ }
408+
409+ function persistCompactHistorySlotHeight ( value : number | null ) {
410+ if ( typeof window === 'undefined' ) return ;
411+ try {
412+ if ( value === null ) {
413+ window . localStorage ?. removeItem ( COMPACT_HISTORY_HEIGHT_STORAGE_KEY ) ;
414+ } else {
415+ window . localStorage ?. setItem ( COMPACT_HISTORY_HEIGHT_STORAGE_KEY , String ( Math . round ( value ) ) ) ;
416+ }
417+ } catch {
418+ // localStorage can be unavailable in restricted hosts; keep the in-memory state.
419+ }
420+ }
421+
422+ // 历史区高度上限的基数:Electron 独立窗口用工作区高度(窗口可能只覆盖部分屏,不能用 innerHeight),
423+ // 网页路径用视口高度。与 styles.css 里默认公式的 63vh / workarea*0.63 取同一基数。
424+ function getCompactHistoryViewportBase ( ) : number {
425+ if ( typeof window === 'undefined' ) return 900 ;
426+ const desktopLayout = ( window as typeof window & {
427+ __nekoDesktopCompactLayout ?: { workArea ?: { height ?: number } | null } | null ;
428+ } ) . __nekoDesktopCompactLayout ;
429+ const workAreaHeight = Number ( desktopLayout ?. workArea ?. height ) ;
430+ if ( isDesktopCompactSurfaceLayoutActive ( ) && Number . isFinite ( workAreaHeight ) && workAreaHeight > 0 ) {
431+ return workAreaHeight ;
432+ }
433+ return window . innerHeight || 900 ;
434+ }
435+
436+ function getCompactHistoryResizePointerY ( event : ReactPointerEvent < HTMLDivElement > ) : number {
437+ const screenY = Number ( event . screenY ) ;
438+ if ( Number . isFinite ( screenY ) ) {
439+ return screenY ;
440+ }
441+ return event . clientY ;
442+ }
443+
376444type SpeechPlaybackState = {
377445 active : boolean ;
378446 turnId ?: string | null ;
@@ -1284,13 +1352,16 @@ export default function App({
12841352 const [ compactInputToolWheelChargeDirection , setCompactInputToolWheelChargeDirection ] = useState < 1 | - 1 | null > ( null ) ;
12851353 const [ compactInputToolWheelChargeReleaseActive , setCompactInputToolWheelChargeReleaseActive ] = useState ( false ) ;
12861354 const [ compactSurfaceResizeWidth , setCompactSurfaceResizeWidth ] = useState < number | null > ( null ) ;
1355+ const [ compactHistorySlotHeight , setCompactHistorySlotHeight ] = useState < number | null > ( readPersistedCompactHistorySlotHeight ) ;
1356+ const [ compactHistoryResizeActive , setCompactHistoryResizeActive ] = useState ( false ) ;
12871357 const [ compactExportHistoryOpen , setCompactExportHistoryOpen ] = useState ( readPersistedCompactExportHistoryOpen ) ;
12881358 const [ compactExportHistoryMounted , setCompactExportHistoryMounted ] = useState ( readPersistedCompactExportHistoryOpen ) ;
12891359 const [ compactExportControlsOpen , setCompactExportControlsOpen ] = useState ( false ) ;
12901360 const [ compactExportPreviewOpen , setCompactExportPreviewOpen ] = useState ( false ) ;
12911361 const [ compactExportSelectedIds , setCompactExportSelectedIds ] = useState < Set < string > > ( ( ) => new Set ( ) ) ;
12921362 const [ compactExportAutoScrollToBottom , setCompactExportAutoScrollToBottom ] = useState ( true ) ;
12931363 const compactSurfaceResizeStateRef = useRef < CompactSurfaceResizeState | null > ( null ) ;
1364+ const compactHistoryResizeStateRef = useRef < CompactHistoryResizeState | null > ( null ) ;
12941365 const compactHistoryVisibilitySuppressClickRef = useRef ( false ) ;
12951366 const compactExportHistoryUnmountTimerRef = useRef < number | null > ( null ) ;
12961367 const submittingRef = useRef ( false ) ;
@@ -2732,6 +2803,149 @@ export default function App({
27322803 finishCompactSurfaceResize ( event ) ;
27332804 } , [ finishCompactSurfaceResize ] ) ;
27342805
2806+ const getCompactHistorySlotMaxHeight = useCallback ( ( ) => {
2807+ const surfaceWidth = getCurrentCompactSurfaceWidth ( ) ;
2808+ const base = getCompactHistoryViewportBase ( ) ;
2809+ // anchor 的 max-height = min(width*1.46, 78%),但 panel 里 scroll 上方有 bar、下方有 controls;
2810+ // 先扣掉这部分非滚动 chrome,scroll 区才不会吃满 anchor 把 controls / 底部气泡顶出可视/可点区。
2811+ const anchorMax = Math . min (
2812+ surfaceWidth * COMPACT_HISTORY_SLOT_MAX_WIDTH_RATIO ,
2813+ base * COMPACT_HISTORY_SLOT_MAX_VIEWPORT_RATIO ,
2814+ ) ;
2815+ return Math . round ( Math . max (
2816+ COMPACT_HISTORY_SLOT_MIN_HEIGHT ,
2817+ anchorMax - COMPACT_HISTORY_SLOT_CHROME_RESERVE ,
2818+ ) ) ;
2819+ } , [ getCurrentCompactSurfaceWidth ] ) ;
2820+
2821+ const getClampedCompactHistorySlotHeight = useCallback ( ( height : number ) => (
2822+ Math . round ( Math . max (
2823+ COMPACT_HISTORY_SLOT_MIN_HEIGHT ,
2824+ Math . min ( height , getCompactHistorySlotMaxHeight ( ) ) ,
2825+ ) )
2826+ ) , [ getCompactHistorySlotMaxHeight ] ) ;
2827+
2828+ // 用户未拖动过时(slot 为 null),起拖高度取 styles.css 默认公式值(width*1.18 / 63%),
2829+ // 保证拖动第一帧从当前可见高度连续起步、不跳变。
2830+ const getCompactHistoryStartHeight = useCallback ( ( ) => {
2831+ // 起拖基准必须是「当前可见高度」(按当前约束 clamp 后)。存量高度可能来自更大屏 / 更宽 surface,
2832+ // 此时面板已被钳到 max、若用 stale 大值做基准,向下拖会出现「先拖一段没反应」的死区。
2833+ if ( compactHistorySlotHeight !== null ) {
2834+ return getClampedCompactHistorySlotHeight ( compactHistorySlotHeight ) ;
2835+ }
2836+ const surfaceWidth = getCurrentCompactSurfaceWidth ( ) ;
2837+ const base = getCompactHistoryViewportBase ( ) ;
2838+ return getClampedCompactHistorySlotHeight ( Math . round ( Math . min (
2839+ surfaceWidth * COMPACT_HISTORY_SLOT_DEFAULT_WIDTH_RATIO ,
2840+ base * COMPACT_HISTORY_SLOT_DEFAULT_VIEWPORT_RATIO ,
2841+ ) ) ) ;
2842+ } , [ compactHistorySlotHeight , getClampedCompactHistorySlotHeight , getCurrentCompactSurfaceWidth ] ) ;
2843+
2844+ const applyCompactHistorySlotHeightVar = useCallback ( ( height : number | null ) => {
2845+ if ( typeof document === 'undefined' ) return ;
2846+ if ( height === null ) {
2847+ document . documentElement . style . removeProperty ( '--compact-history-slot-height' ) ;
2848+ } else {
2849+ document . documentElement . style . setProperty (
2850+ '--compact-history-slot-height' ,
2851+ `${ getClampedCompactHistorySlotHeight ( height ) } px` ,
2852+ ) ;
2853+ }
2854+ // CSS 变量变更不会自己通知宿主;让宿主重算 history 命中 rect / Electron 窗口 bounds / 鼠标穿透区。
2855+ if ( typeof window !== 'undefined' ) {
2856+ window . dispatchEvent ( new CustomEvent ( 'neko:compact-interaction-geometry-refresh' ) ) ;
2857+ }
2858+ } , [ getClampedCompactHistorySlotHeight ] ) ;
2859+
2860+ const finishCompactHistoryResize = useCallback ( ( event ?: ReactPointerEvent < HTMLDivElement > ) => {
2861+ const resizeState = compactHistoryResizeStateRef . current ;
2862+ if ( ! resizeState ) return ;
2863+ if ( event && resizeState . pointerId !== event . pointerId ) return ;
2864+ // 只在真正拖动过才落库:纯点击不该把响应式默认高度锁成固定像素值(否则之后视口/宽度变化不再响应)。
2865+ if ( resizeState . moved ) {
2866+ persistCompactHistorySlotHeight ( resizeState . lastHeight ) ;
2867+ setCompactHistorySlotHeight ( resizeState . lastHeight ) ;
2868+ }
2869+ const captureTarget = resizeState . captureTarget ;
2870+ if ( captureTarget && typeof captureTarget . releasePointerCapture === 'function' ) {
2871+ try {
2872+ if ( captureTarget . hasPointerCapture ?.( resizeState . pointerId ) ) {
2873+ captureTarget . releasePointerCapture ( resizeState . pointerId ) ;
2874+ }
2875+ } catch ( _ ) { }
2876+ }
2877+ compactHistoryResizeStateRef . current = null ;
2878+ setCompactHistoryResizeActive ( false ) ;
2879+ } , [ ] ) ;
2880+
2881+ const handleCompactHistoryResizePointerDown = useCallback ( ( event : ReactPointerEvent < HTMLDivElement > ) => {
2882+ if ( ! isCompactSurface ) return ;
2883+ if ( event . pointerType === 'mouse' && event . button !== 0 ) return ;
2884+ event . preventDefault ( ) ;
2885+ event . stopPropagation ( ) ;
2886+ const startHeight = getCompactHistoryStartHeight ( ) ;
2887+ compactHistoryResizeStateRef . current = {
2888+ pointerId : event . pointerId ,
2889+ startPointerY : getCompactHistoryResizePointerY ( event ) ,
2890+ startHeight,
2891+ lastHeight : getClampedCompactHistorySlotHeight ( startHeight ) ,
2892+ moved : false ,
2893+ captureTarget : event . currentTarget ,
2894+ } ;
2895+ setCompactHistoryResizeActive ( true ) ;
2896+ try {
2897+ event . currentTarget . setPointerCapture ?.( event . pointerId ) ;
2898+ } catch ( _ ) { }
2899+ } , [ getClampedCompactHistorySlotHeight , getCompactHistoryStartHeight , isCompactSurface ] ) ;
2900+
2901+ const handleCompactHistoryResizePointerMove = useCallback ( ( event : ReactPointerEvent < HTMLDivElement > ) => {
2902+ const resizeState = compactHistoryResizeStateRef . current ;
2903+ if ( ! resizeState || resizeState . pointerId !== event . pointerId ) return ;
2904+ event . preventDefault ( ) ;
2905+ event . stopPropagation ( ) ;
2906+ // bar 在堆砌区顶部:上拖(deltaY < 0)增高,下拖减高。
2907+ const deltaY = getCompactHistoryResizePointerY ( event ) - resizeState . startPointerY ;
2908+ if ( deltaY !== 0 ) resizeState . moved = true ;
2909+ const nextHeight = getClampedCompactHistorySlotHeight ( resizeState . startHeight - deltaY ) ;
2910+ resizeState . lastHeight = nextHeight ;
2911+ setCompactHistorySlotHeight ( nextHeight ) ;
2912+ applyCompactHistorySlotHeightVar ( nextHeight ) ;
2913+ } , [ applyCompactHistorySlotHeightVar , getClampedCompactHistorySlotHeight ] ) ;
2914+
2915+ const handleCompactHistoryResizePointerUp = useCallback ( ( event : ReactPointerEvent < HTMLDivElement > ) => {
2916+ event . preventDefault ( ) ;
2917+ event . stopPropagation ( ) ;
2918+ finishCompactHistoryResize ( event ) ;
2919+ } , [ finishCompactHistoryResize ] ) ;
2920+
2921+ const handleCompactHistoryResizePointerCancel = useCallback ( ( event : ReactPointerEvent < HTMLDivElement > ) => {
2922+ event . preventDefault ( ) ;
2923+ event . stopPropagation ( ) ;
2924+ finishCompactHistoryResize ( event ) ;
2925+ } , [ finishCompactHistoryResize ] ) ;
2926+
2927+ // 把已存/恢复的高度写进 CSS 变量(覆盖默认公式);slot 为 null 时清掉、回落默认。
2928+ useEffect ( ( ) => {
2929+ if ( ! isCompactSurface ) return ;
2930+ applyCompactHistorySlotHeightVar ( compactHistorySlotHeight ) ;
2931+ } , [ applyCompactHistorySlotHeightVar , compactHistorySlotHeight , isCompactSurface ] ) ;
2932+
2933+ // 视口 / 工作区 / compact surface 宽度变化后,按新约束重写 CSS 变量(用新 max clamp 显示高度)。
2934+ // 刻意不改 state、不覆盖 storage:存量 raw 值保留,换屏 / 改宽再放大时能恢复;起拖死区另由
2935+ // getCompactHistoryStartHeight 对基准 clamp 解决。
2936+ useEffect ( ( ) => {
2937+ if ( ! isCompactSurface ) return undefined ;
2938+ const reapplySlotHeight = ( ) => applyCompactHistorySlotHeightVar ( compactHistorySlotHeight ) ;
2939+ window . addEventListener ( 'resize' , reapplySlotHeight ) ;
2940+ window . addEventListener ( 'neko:desktop-compact-layout-change' , reapplySlotHeight ) ;
2941+ window . addEventListener ( 'neko:compact-surface-resize-width-change' , reapplySlotHeight ) ;
2942+ return ( ) => {
2943+ window . removeEventListener ( 'resize' , reapplySlotHeight ) ;
2944+ window . removeEventListener ( 'neko:desktop-compact-layout-change' , reapplySlotHeight ) ;
2945+ window . removeEventListener ( 'neko:compact-surface-resize-width-change' , reapplySlotHeight ) ;
2946+ } ;
2947+ } , [ applyCompactHistorySlotHeightVar , compactHistorySlotHeight , isCompactSurface ] ) ;
2948+
27352949 useEffect ( ( ) => {
27362950 if ( ! isCompactSurface || compactSurfaceEffectiveWidth === null ) {
27372951 applyCompactSurfaceResizeWidthVar ( null ) ;
@@ -5212,6 +5426,11 @@ export default function App({
52125426 isDropTargetAt = { isCompactHistoryDropTargetAt }
52135427 onDropToTarget = { handleCompactHistoryDropToAvatar }
52145428 onDragStateChange = { onCompactHistoryDragStateChange }
5429+ historyResizeActive = { compactHistoryResizeActive }
5430+ onHistoryResizePointerDown = { handleCompactHistoryResizePointerDown }
5431+ onHistoryResizePointerMove = { handleCompactHistoryResizePointerMove }
5432+ onHistoryResizePointerUp = { handleCompactHistoryResizePointerUp }
5433+ onHistoryResizePointerCancel = { handleCompactHistoryResizePointerCancel }
52155434 />
52165435 ) : null ;
52175436 const compactExportHistoryNode = compactExportHistoryElement ;
0 commit comments