Skip to content

Commit c27d790

Browse files
wehosHongzhi Wenclaude
authored
feat(compact): 毛绒球折叠入口 + 禁用自动变猫开关 + compact 对话条/历史区打磨 (#1672)
* feat(compact): 毛绒球折叠入口 + 禁用自动变猫开关 + compact 最短宽度降至 280 - 输入态/胶囊态左侧握把改为毛绒球:单击折叠为 minimized,长按拖动整个 surface(复用工具轮盘 origin-drag 手势区分点击/拖动);App.tsx 新增 onCompactMinimizeRequest 回调,宿主接 setChatSurfaceMode('minimized') - 新增「自动变猫」开关(聊天设置侧面板):app-auto-goodbye 自管开关(独立 localStorage + user-disabled 抑制原因),8 语言 i18n - compact 桌面端最短宽度 430→280(新增 COMPACT_SURFACE_DESKTOP_MIN_WIDTH,默认仍 430;并修 metrics 把拖窄宽度顶回 430 的隐患) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 毛绒球放大左移 + resize bar 加长 + 历史 handle 展开加长 + 回滚热区收窄 - 毛绒球折叠球半径 +20%(28→34,icon 26→31),左移紧贴对话框(padding-left 8→4) - 顶部历史 resize bar 视觉条加长 100%(40→80px) - 历史展开 handle(蓝色 toggle bar)展开态加长(is-open 时 handle 宽度上调) - 回滚 fd138cd「resize bar 调尺寸热区联动浮现」,恢复 hover 整个历史区即浮现 resize bar - 修默认宽度隐患:拆出 COMPACT_SURFACE_DEFAULT_WIDTH(430),getCurrentCompactSurfaceWidth fallback 用默认宽度而非 resize 下限(默认仍 430,resize 可到 280) - 同步更新 App.test.tsx(毛绒球入口测试、resize 下限 280)与 test_react_chat_window_static.py(padding、热区回滚) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(compact): 去掉历史气泡拖拽,恢复文本可选(保留点击勾选) 彻底删除 compact 历史气泡「拖到桌宠 avatar」子系统的 React 侧实现,让历史对话气泡内文本可被鼠标正常选中复制;保留点击勾选(导出选择)与图片显示。 - CompactExportHistoryPanel.tsx: 删整套指针拖拽状态机(handlePointerDown/Move/finishPointer/startCompactHistoryDrag 等约 40 函数 + drag-layer 渲染),气泡只留 onClick/onKeyDown 勾选;气泡加 user-select:text / cursor:text - App.tsx / message-schema.ts: 删 onCompactHistoryDrop/onCompactHistoryDragStateChange props + drop payload 构造链 + drag schema 类型 - app-react-chat-window.js: 删宿主侧 drop handler/setter(app-buttons.js 调用经 typeof 守卫安全降级 no-op) - 删 12 个拖拽相关 vitest + 加 1 个「点击勾选 + 文本可选」用例;同步 message-schema.test / test_react_chat_window_static - 注: Electron preload(lanlan_frd) drop 接收端 + app-buttons.js drop-sender 暂留为 dead code,待跨仓库单独清理(功能版取舍) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 毛绒球继续放大左移(41px / icon 37 / padding-left 2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 历史区最大高度去掉对话条宽度上限,只受屏幕高度约束 getCompactHistorySlotMaxHeight 去掉 W×1.46 那条(纯视觉比例约束),只保留屏幕可用高度×0.78 − chrome 预留;删去 unused 的 COMPACT_HISTORY_SLOT_MAX_WIDTH_RATIO。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(compact): 历史区缩小改为「下端锚定、上端折叠裁剪」,消除每帧 reflow 拖 resize bar 缩小历史区时,原本每帧改 scroll 盒 height + scroll-content min-height:100% 跟变 → 内容每帧 reflow 重排(抖动)。改为: - 拖动期间(anchor data-compact-export-history-resizing)把 scroll-content 布局高度锚定到最大高度(--compact-history-slot-max-height,App.tsx applyCompactHistorySlotHeightVar 设),缩小只裁可视窗口、不 reflow 内容;配合顶部 mask 渐隐做'上端折起',geometry-refresh 时钉底(scrollTop=底)锚定下端 → 卷帘从上往下收 - 稳态保持 min-height:100%(内容少时无空白可滚,零 regression);拖动结束回落 100% 单次 reflow 贴合 - Electron 穿透区由 getBoundingClientRect 实测 + clip 自动跟随,无需改 preload Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 去掉历史气泡随机偏移/旋转/宽度,改规整统一宽度 getCompactHistoryBubbleTone 不再按 hash 生成随机宽度比例 / stagger-x 水平偏移 / rotate 旋转(强迫症友好);气泡宽度统一为 calc(100% - 48px)(比对话条窄约 48px),stagger-x=0、rotate=0。仅保留按分组的垂直间距 gap-before。删去 unused 的 widthSteps/offsetSteps/baseOffset/signedOffset/rotateSteps;同步更新 spacing tokens 用例断言。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 修历史区底部气泡阴影被裁 + 缩小高度切换跳变 气泡 box-shadow(0 7px 18px,下沿约 25px)不占盒模型,被 scroll 盒 overflow 裁(底部仅 4px padding);且稳态 min-height:100% 与拖动期 min-height:max 切换改变了底部气泡相对裁剪边的位置 → resize 起手/松手各跳一次。 修:scroll-content 加 padding-bottom: var(--compact-history-bubble-shadow-reserve, 26px)(落在可滚动内容盒,border-box 下计入 min-height,稳态盒高仍恰为 100%、不引入多余可滚区),最下方气泡下方始终留够阴影空间;稳态/拖动期同一常量 → 切换无跳变、稳态阴影完整不裁、气泡不再上浮。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 历史区宽度收窄到对话条宽 + 回退无效的气泡阴影 padding - 历史区 anchor 宽度从 对话条宽×1.36 改为 ×1.0(用户要改的是历史区容器太宽,不是气泡;气泡 max-ratio 维持 calc(100%-48px) 不动,自然落回比输入框窄约 48px) - 回退上一版给 scroll-content 加的 padding-bottom:那个诊断方向错了——实测既没解决底部气泡阴影被裁,反而把截断线与输入框间距拉大、更丑。底部阴影截断 + 缩小跳变(min-height 切换)仍待用实测 inspect 真正裁切元素后再修,不再纯推理。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 历史区贴近输入框(留白 34→10) + 修底部气泡阴影被裁 - anchor bottom 间距 34px→10px:历史区底边贴近输入框,去掉「离输入框太远」的大留白 - scroll 盒(容器)加 padding-bottom 26px 给最下方气泡 box-shadow(下沿~25px)留空间:padding 区在 scrollport 内、不被 overflow-y:auto 裁,阴影完整。关键是落在 scroll 盒「容器」本身——上一版加在子元素 scroll-content 的 padding-bottom 会被 Chromium 排除出可滚区、钉底后仍贴裁剪边而无效(这是上次诊断错的真因)。border-box 下不增 scroll 盒总高(=region)、只让出底部内容区,不增整体留白。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 修底部气泡阴影截断 + 展开经过新气泡抖动 实测对照(vite dev + 浏览器测量盒模型/scrollHeight)定位两个真因: 1. 阴影截断:flex/grid column 滚动容器的「末端 padding」会被 Chromium 排除出可滚区(scrollHeight 不含它),所以之前给 scroll 盒加 padding-bottom 一直无效(实测 flex 下加不加 scrollHeight 都是 1049,block 下才 1075)。改 .compact-export-history-scroll display:flex→block——底部对齐/缩小不 reflow 由子元素 scroll-content 的 flex+min-height:100%+margin-top:auto 负责(实测 block 下不变),底部 padding 进可滚区、最下气泡 box-shadow 完整漏出。anchor bottom 10→6 外边界更贴输入框,净留白不增(阴影落在底部 padding 区、非新增空白)。 2. 展开抖动:每帧无条件钉底(scrollTop=scrollHeight-clientHeight)在增高方向冗余、与正被揭出的上方新气泡重排打架 → 抖。改方向化钉底:记录上帧 clientHeight,只在缩小(变小)时钉底锚回下端,增高/不变交给浏览器自然 clamp。已修好的折叠(缩小)下端锚定不变。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 历史区底边到输入框间距 6px→2px,贴近输入框 间距来源已查明:就是 anchor 的 bottom 外边距(历史区底边钉在输入框顶上方的固定距离)。controls 导出按钮区是 controlsOpen 条件渲染、平时不占;scrollbar-hit 是 absolute 不占流;scroll 盒底部 padding 是给气泡阴影留的区(阴影占 25px)。故主间距=anchor bottom,减到 2px 后气泡阴影底到输入框约 3px。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 历史区到输入框再压紧 10px(scroll padding-bottom 26→16) 实测(浏览器量盒模型 + canvas 量 box-shadow 衰减)定位上次「没变化」的真因:用户看到的间距是最下气泡「实体方块底」到输入框 ~28px、由 scroll padding-bottom 主导(给 box-shadow 漏出留的空间);anchor bottom 只控「裁剪边」到输入框那 2px,肉眼分不出 6/2,所以改它几乎无感。box-shadow 0 7px 18px 数学下沿 25px,但 canvas 实测视觉可见下沿仅 ~13px(清晰段 ~9px)。故 padding-bottom 26→16:气泡实体底 28→18px(明显紧 10px),裁剪净空 15.9px 仍包住阴影可见段、阴影不被裁(上轮成果保留)。anchor bottom 维持 2px(再压到 0 会与输入框视觉粘连、收益≈0)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 处理 Codex/CodeRabbit review 三处 - [P1] chatWindowPropsSchema 增补 onCompactMinimizeRequest:之前只加了 App.tsx 的 TS 类型,hosted path 走 parseChatWindowProps(zod)会 strip 掉它,真实 Electron 里毛绒球点击拿到 undefined。 - [P2] loadCompactSurfaceStoredWidth desktop 下限 COMPACT_SURFACE_MAX_WIDTH(430)→COMPACT_SURFACE_DESKTOP_MIN_WIDTH:之前改 metrics/clampForSide 漏了这个 loader,导致拖到 280~429 的存量宽度复原时被顶回 430。 - [P2] CSS .compact-export-history-anchor max-height 去掉 width*1.46、改 78vh,与 JS getCompactHistorySlotMaxHeight(只受屏高)对齐,避免窄宽+高屏时 hit region 被 anchor 裁出死区。 build + vitest 168 + python 44 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 历史气泡划词复制时不再误触发导出勾选(Codex P2) 气泡是 user-select:text(故意覆盖 role=button 的 none,支持划词复制),但 export 模式下 onClick 又 toggle 勾选。拖选文字 mouseup 会再冒出一次 click,导致划词复制被误判为勾选。handleClick 在 toggle 前加折叠选区 guard:存在非空非折叠选区时跳过,单击(选区折叠)照常勾选;键盘 Enter/Space 路径不受影响。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4624fb commit c27d790

19 files changed

Lines changed: 607 additions & 3491 deletions

frontend/react-neko-chat/src/App.test.tsx

Lines changed: 30 additions & 955 deletions
Large diffs are not rendered by default.

frontend/react-neko-chat/src/App.tsx

Lines changed: 55 additions & 179 deletions
Large diffs are not rendered by default.

frontend/react-neko-chat/src/CompactExportHistoryPanel.test.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,98 @@ describe('CompactExportHistoryPanel', () => {
6262
expect(container.querySelector('.compact-export-history-resize-bar.is-active')).not.toBeNull();
6363
});
6464

65+
it('flags the anchor as resizing so content height locks to max (no reflow on shrink)', () => {
66+
const { container, rerender } = renderPanel({ previewOpen: false, visibilityState: 'open', historyResizeActive: false });
67+
const anchor = container.querySelector('.compact-export-history-anchor');
68+
expect(anchor?.getAttribute('data-compact-export-history-resizing')).toBe('false');
69+
rerender(<CompactExportHistoryPanel {...createPanelProps({ previewOpen: false, visibilityState: 'open', historyResizeActive: true })} />);
70+
expect(anchor?.getAttribute('data-compact-export-history-resizing')).toBe('true');
71+
});
72+
73+
it('re-pins the history list to the bottom on geometry refresh only while shrinking and auto-following', () => {
74+
// 方向化钉底:只有「缩小」(可视窗口高度变小)才强制把可视窗口锚回下端;「增高」方向交给浏览器
75+
// 自然 clamp(每帧强写 scrollTop 会与正被揭出的上方新气泡重排打架→展开抖动);非 auto-following 一律不动。
76+
const scrollTopValues: number[] = [];
77+
const scrollTopByElement = new WeakMap<HTMLElement, number>();
78+
// 可控的可视窗口高度,模拟拖动 resize bar 时 clientHeight 的逐帧变化。
79+
let mockClientHeight = 300;
80+
const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight');
81+
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight');
82+
const scrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop');
83+
84+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
85+
configurable: true,
86+
// resizing 态内容锚定在 max,scrollHeight 在一次拖动里保持不变。
87+
get() {
88+
return this.classList.contains('compact-export-history-scroll') ? 640 : 0;
89+
},
90+
});
91+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
92+
configurable: true,
93+
get() {
94+
return this.classList.contains('compact-export-history-scroll') ? mockClientHeight : 0;
95+
},
96+
});
97+
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
98+
configurable: true,
99+
get() {
100+
return scrollTopByElement.get(this) ?? 0;
101+
},
102+
set(value: number) {
103+
scrollTopByElement.set(this, value);
104+
if (this.classList.contains('compact-export-history-scroll')) {
105+
scrollTopValues.push(value);
106+
}
107+
},
108+
});
109+
110+
try {
111+
// auto-following off:即便缩小也不该把视口从用户当前滚动位置拽走。
112+
const { rerender } = renderPanel({ previewOpen: false, visibilityState: 'open', autoScrollToBottom: false });
113+
scrollTopValues.length = 0;
114+
mockClientHeight = 200; // 缩小
115+
act(() => {
116+
window.dispatchEvent(new CustomEvent('neko:compact-interaction-geometry-refresh'));
117+
});
118+
expect(scrollTopValues).not.toContain(440);
119+
120+
// auto-following on,但方向是「增高」:不强制钉底,交给浏览器自然 clamp(消除展开抖动)。
121+
// 上一次 refresh 已把方向基线(lastGeometryClientHeight)更新到 200,这里从 200 → 360 是增高。
122+
rerender(<CompactExportHistoryPanel {...createPanelProps({ previewOpen: false, visibilityState: 'open', autoScrollToBottom: true })} />);
123+
scrollTopValues.length = 0;
124+
mockClientHeight = 360; // 增高
125+
act(() => {
126+
window.dispatchEvent(new CustomEvent('neko:compact-interaction-geometry-refresh'));
127+
});
128+
expect(scrollTopValues).toHaveLength(0);
129+
130+
// auto-following on,方向是「缩小」:钉底到 scrollHeight - clientHeight = 640 - 200 = 440。
131+
// 上一帧 refresh 已把基线更新到 360,这里 360 → 200 是缩小。
132+
scrollTopValues.length = 0;
133+
mockClientHeight = 200; // 缩小
134+
act(() => {
135+
window.dispatchEvent(new CustomEvent('neko:compact-interaction-geometry-refresh'));
136+
});
137+
expect(scrollTopValues).toContain(440);
138+
} finally {
139+
if (scrollHeightDescriptor) {
140+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', scrollHeightDescriptor);
141+
} else {
142+
Reflect.deleteProperty(HTMLElement.prototype, 'scrollHeight');
143+
}
144+
if (clientHeightDescriptor) {
145+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', clientHeightDescriptor);
146+
} else {
147+
Reflect.deleteProperty(HTMLElement.prototype, 'clientHeight');
148+
}
149+
if (scrollTopDescriptor) {
150+
Object.defineProperty(HTMLElement.prototype, 'scrollTop', scrollTopDescriptor);
151+
} else {
152+
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTop');
153+
}
154+
}
155+
});
156+
65157
it('drops the history resize bar hit-region when a choice prompt sits above', () => {
66158
const { container } = renderPanel({ previewOpen: false, visibilityState: 'open', choiceLayerAbove: true });
67159
const bar = container.querySelector('.compact-export-history-resize-bar');

0 commit comments

Comments
 (0)