Skip to content

Commit be21de8

Browse files
committed
fix(chat): unmount closed compact history
恢复紧凑历史关闭动画结束后的卸载流程,避免历史收起后继续接收新文字/语音消息并闪现气泡。 保留历史操作栏状态与音乐播放器回落到 composer 挂载点的语义,并补充关闭后新消息不闪、重新展开后正常显示最新历史的回归测试。 验证: - npm test -- --run App.test.tsx - bash build_frontend.sh - .venv/bin/pytest tests/unit/test_react_chat_window_static.py -q - git diff --check
1 parent 04f936d commit be21de8

3 files changed

Lines changed: 99 additions & 14 deletions

File tree

docs/design/compact-chat-mode-design.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@
9898
10. 工具转轮通过 portal 挂到 `document.body`,并以 `data-compact-geometry-item="toolFan"` 进入 geometry。
9999
11. 历史层由 `CompactExportHistoryPanel` 挂载到 `app-shell` 内,锚点是 `.compact-export-history-anchor`,并以 `data-compact-geometry-item="history"` 进入 geometry。
100100
12. 历史显隐由常驻 `.compact-history-visibility-handle` 控制,使用 `data-compact-geometry-item="historyHandle"` 进入 geometry。
101-
13. ChoicePrompt 和 GalGame options 共享 compact choice layer;ChoicePrompt 优先,GalGame 在无 ChoicePrompt 时显示。
101+
13. 历史关闭时,`CompactExportHistoryPanel` 只在 closing 动画期间短暂保留挂载;`COMPACT_EXPORT_HISTORY_VISIBILITY_ANIMATION_MS` 到期后必须卸载,避免关闭态继续接收新 `messages` 并闪现历史气泡。
102+
14. ChoicePrompt 和 GalGame options 共享 compact choice layer;ChoicePrompt 优先,GalGame 在无 ChoicePrompt 时显示。
102103

103104
### NEKO 宿主与静态桥
104105

@@ -125,6 +126,7 @@
125126
- 监听 `neko:compact-surface-drag-grab`(来自 React 工具轮盘原点拖拽),非 Electron 时以事件坐标为锚启动 compact surface 本体拖拽(复用既有 startDrag/全局 mousemove/mouseup 与落点 click 守卫)。Electron 由 `preload-chat-react.js` 监听同一事件改走原生窗口拖拽。
126127
5. `static/app-buttons.js` 是发送桥之一。compact history 文本发送必须带清晰 session / request 语义,不能让已有 composer 附件在 deferred send 中被误带上。
127128
6. 语音模式 / `composerHidden` 下的 history drop 只保留前端拖拽、命中和收束动效;真实发送必须在 `sendCompactHistoryDropPayload` 边界跳过,不能通过改 React 拖拽 phase 或样式来伪装。
129+
7. `static/music_ui.js` 的音乐播放器优先挂到打开且可交互的 `.compact-export-history-music-mount`;历史关闭或卸载后应回落到 composer 内唯一 `#music-player-mount`。不要为了保住音乐挂载点让整个历史消息面板长期常驻。
128130

129131
### NEKO-PC 桌面壳
130132

@@ -365,13 +367,16 @@ Compact 历史默认在初次启动时显示。历史列表本身由常驻展开
365367
7. 没有 `neko.reactChatWindow.compactExportHistoryOpen` 持久化记录时,历史默认打开;用户显式收起后持久化为 `false`
366368
8. 常驻 `.compact-history-visibility-handle` 只控制历史列表显隐;它关闭历史时不清除操作栏打开状态。
367369
9. 工具转轮历史/导出按钮控制操作栏显示;如果历史关闭时点击该按钮,应先打开历史并显示操作栏。
368-
10. 操作栏显示期间进入选择模式:气泡点击 / 键盘 Enter / Space 可以选中或取消选中历史消息。
369-
11. 操作栏隐藏时退出选择模式:必须清空当前选中项,并禁止继续通过点击或键盘选择;拖拽源识别和拖拽发送不受这个选择模式限制。
370-
12. 操作栏包含选择和导出动作,如计数、全选、取消/清空、反选、导出预览等;操作栏自身进入 history hit region。
371-
13. 选择状态、导出预览和操作栏显示状态由 React state 管理;操作栏状态可以跨历史显隐保留,但只在历史实际打开时算作可见。
372-
14. 预览关闭时要清理 stale export error 和必要 preview lifecycle 状态,避免重新打开显示旧错误。
373-
15. 历史透明区域不能长期遮挡后方;可见气泡、按钮、预览控件和必要滚动区域可命中,气泡间透明区应尽量穿透。
374-
16. GalGame / ChoicePrompt 出现时,选项层在历史层上方。
370+
10. 历史关闭后可以播放 closing 动画;动画结束后历史面板必须卸载。关闭期间新增的文字/语音消息不得进入历史面板 DOM,也不得在历史区域短暂闪现;重新打开历史时再按最新 `messages` 完整渲染。
371+
11. closing 期间历史气泡、操作栏、音乐 mount 和预览控件都不进入 history hit region,不保留按钮语义、键盘焦点或透明命中区。
372+
12. 操作栏显示期间进入选择模式:气泡点击 / 键盘 Enter / Space 可以选中或取消选中历史消息。
373+
13. 操作栏隐藏时退出选择模式:必须清空当前选中项,并禁止继续通过点击或键盘选择;拖拽源识别和拖拽发送不受这个选择模式限制。
374+
14. 操作栏包含选择和导出动作,如计数、全选、取消/清空、反选、导出预览等;操作栏自身进入 history hit region。
375+
15. 选择状态、导出预览和操作栏显示状态由 React state 管理;操作栏状态可以跨历史显隐保留,但只在历史实际打开时算作可见。
376+
16. 历史面板内有专用 `.compact-export-history-music-mount`,只在历史打开且可交互时作为音乐播放器优先挂载点;关闭/卸载后播放器必须回落到 composer 的 `#music-player-mount`
377+
17. 预览关闭时要清理 stale export error 和必要 preview lifecycle 状态,避免重新打开显示旧错误。
378+
18. 历史透明区域不能长期遮挡后方;可见气泡、按钮、预览控件和必要滚动区域可命中,气泡间透明区应尽量穿透。
379+
19. GalGame / ChoicePrompt 出现时,选项层在历史层上方。
375380

376381
## 历史气泡拖拽与发送合同
377382

@@ -574,7 +579,10 @@ Surface:
574579
5. 操作栏隐藏后再次打开,选择、全选、反选、清空、导出预览可用。
575580
6. 历史列表收起再展开时,操作栏显示状态可保留,但按钮高亮只反映当前实际可见状态。
576581
7. 历史透明区不遮挡后方。
577-
8. 预览关闭不会保留旧 error。
582+
8. 历史关闭动画结束后,历史面板卸载;关闭期间继续发生文字/语音对话时,历史区域不出现新气泡闪现。
583+
9. 历史重新展开后,关闭期间产生的新消息会按最新 `messages` 正常出现在历史中。
584+
10. 播放中的音乐栏在历史打开时可挂到历史面板,历史关闭/卸载后能回落到 composer 挂载点。
585+
11. 预览关闭不会保留旧 error。
578586

579587
历史拖拽:
580588

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

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -448,9 +448,7 @@ describe('App', () => {
448448
await vi.advanceTimersByTimeAsync(COMPACT_EXPORT_HISTORY_VISIBILITY_ANIMATION_MS);
449449
});
450450

451-
expect(container.querySelector('.compact-export-history-anchor')).not.toBeNull();
452-
expect(container.querySelector('.compact-export-history-anchor')).toHaveAttribute('data-compact-export-history-visibility', 'closing');
453-
expect(container.querySelector('.compact-export-history-anchor')).toHaveAttribute('data-compact-export-history-open', 'false');
451+
expect(container.querySelector('.compact-export-history-anchor')).toBeNull();
454452
expect(container.querySelectorAll('#music-player-mount')).toHaveLength(1);
455453
expect(container.querySelector('.composer-panel #music-player-mount')).not.toBeNull();
456454
expect(container.querySelector('.compact-export-history-panel #music-player-mount')).toBeNull();
@@ -475,6 +473,70 @@ describe('App', () => {
475473
expect(exportButton).toHaveAttribute('aria-pressed', 'false');
476474
});
477475

476+
it('keeps compact history hidden for new conversation messages after the user closes it', async () => {
477+
const initialMessage = parseChatMessage({
478+
id: 'assistant-history-before-close',
479+
role: 'assistant',
480+
author: 'Neko',
481+
time: '10:00',
482+
createdAt: 1,
483+
blocks: [{ type: 'text', text: 'I am visible before closing.' }],
484+
status: 'sent',
485+
});
486+
const userMessage = parseChatMessage({
487+
id: 'user-history-after-close',
488+
role: 'user',
489+
author: 'You',
490+
time: '10:01',
491+
createdAt: 2,
492+
blocks: [{ type: 'text', text: 'This should not flash while history is closed.' }],
493+
status: 'sent',
494+
});
495+
const assistantMessage = parseChatMessage({
496+
id: 'assistant-history-after-close',
497+
role: 'assistant',
498+
author: 'Neko',
499+
time: '10:02',
500+
createdAt: 3,
501+
blocks: [{ type: 'text', text: 'But it should appear after reopening history.' }],
502+
status: 'sent',
503+
});
504+
505+
vi.useFakeTimers();
506+
try {
507+
const { container, rerender } = render(
508+
<App chatSurfaceMode="compact" compactChatState="input" messages={[initialMessage]} />,
509+
);
510+
expect(container.querySelector('[data-compact-export-history-message-id="assistant-history-before-close"]')).not.toBeNull();
511+
512+
fireEvent.click(container.querySelector<HTMLButtonElement>('.compact-history-visibility-handle')!);
513+
expect(container.querySelector('.compact-export-history-anchor')).toHaveAttribute('data-compact-export-history-visibility', 'closing');
514+
515+
await act(async () => {
516+
await vi.advanceTimersByTimeAsync(COMPACT_EXPORT_HISTORY_VISIBILITY_ANIMATION_MS);
517+
});
518+
expect(container.querySelector('.compact-export-history-anchor')).toBeNull();
519+
520+
rerender(
521+
<App
522+
chatSurfaceMode="compact"
523+
compactChatState="input"
524+
messages={[initialMessage, userMessage, assistantMessage]}
525+
/>,
526+
);
527+
expect(container.querySelector('.compact-export-history-anchor')).toBeNull();
528+
expect(container.querySelector('[data-compact-export-history-message-id="user-history-after-close"]')).toBeNull();
529+
expect(container.querySelector('[data-compact-export-history-message-id="assistant-history-after-close"]')).toBeNull();
530+
531+
fireEvent.click(container.querySelector<HTMLButtonElement>('.compact-history-visibility-handle')!);
532+
expect(container.querySelector('.compact-export-history-anchor')).toHaveAttribute('data-compact-export-history-visibility', 'open');
533+
expect(container.querySelector('[data-compact-export-history-message-id="user-history-after-close"]')).not.toBeNull();
534+
expect(container.querySelector('[data-compact-export-history-message-id="assistant-history-after-close"]')).not.toBeNull();
535+
} finally {
536+
vi.useRealTimers();
537+
}
538+
});
539+
478540
it('restores compact inline history from persisted open state after remount', () => {
479541
const message = parseChatMessage({
480542
id: 'assistant-history-persisted',

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,6 +1288,7 @@ export default function App({
12881288
const [compactExportAutoScrollToBottom, setCompactExportAutoScrollToBottom] = useState(true);
12891289
const compactSurfaceResizeStateRef = useRef<CompactSurfaceResizeState | null>(null);
12901290
const compactHistoryVisibilitySuppressClickRef = useRef(false);
1291+
const compactExportHistoryUnmountTimerRef = useRef<number | null>(null);
12911292
const submittingRef = useRef(false);
12921293
const lastRollbackKeyRef = useRef('');
12931294
const lastToolCursorResetKeyRef = useRef('');
@@ -1545,17 +1546,31 @@ export default function App({
15451546
[compactExportSelectableMessages],
15461547
);
15471548
const compactExportSelectableCount = compactExportSelectableMessages.length;
1549+
const clearCompactExportHistoryUnmountTimer = useCallback(() => {
1550+
if (compactExportHistoryUnmountTimerRef.current === null) return;
1551+
window.clearTimeout(compactExportHistoryUnmountTimerRef.current);
1552+
compactExportHistoryUnmountTimerRef.current = null;
1553+
}, []);
15481554
const openCompactExportHistory = useCallback(() => {
1555+
clearCompactExportHistoryUnmountTimer();
15491556
setCompactExportHistoryMounted(true);
15501557
setCompactExportHistoryOpen(true);
15511558
persistCompactExportHistoryOpen(true);
15521559
setCompactExportAutoScrollToBottom(true);
1553-
}, []);
1560+
}, [clearCompactExportHistoryUnmountTimer]);
15541561
const closeCompactExportHistory = useCallback(() => {
1562+
clearCompactExportHistoryUnmountTimer();
15551563
setCompactExportHistoryOpen(false);
15561564
persistCompactExportHistoryOpen(false);
15571565
setCompactExportPreviewOpen(false);
1558-
}, []);
1566+
compactExportHistoryUnmountTimerRef.current = window.setTimeout(() => {
1567+
setCompactExportHistoryMounted(false);
1568+
compactExportHistoryUnmountTimerRef.current = null;
1569+
}, COMPACT_EXPORT_HISTORY_VISIBILITY_ANIMATION_MS);
1570+
}, [clearCompactExportHistoryUnmountTimer]);
1571+
useEffect(() => () => {
1572+
clearCompactExportHistoryUnmountTimer();
1573+
}, [clearCompactExportHistoryUnmountTimer]);
15591574
const handleCompactHistoryVisibilityToggle = useCallback(() => {
15601575
if (compactExportHistoryOpen) {
15611576
closeCompactExportHistory();

0 commit comments

Comments
 (0)