Skip to content

Commit 2866956

Browse files
authored
feat(chat): refine compact history controls and selection mode (#1596)
* Refine compact history controls Move compact history visibility to a persistent handle while the export tool toggles the history action controls. Add separate controls state, updated geometry metadata, enlarged handle styling, localized labels, and focused tests for persisted/open states. * Finalize compact history selection mode Default compact history to open when no preference is stored and preserve history action controls across visibility toggles. Require the action controls to be visible for history selection, clear selected messages when the controls hide, and document the final compact history contract. * Remove superseded compact chat design notes Drop the older compact chat geometry and home compact design notes now that the consolidated compact chat mode design document is present. * fix(chat): tighten compact history interaction contracts 修正紧凑历史消息在操作栏隐藏时仍暴露可选按钮语义的问题,仅在 controlsOpen 且消息可选时提供 role、aria-pressed 和可聚焦状态。 将常驻历史展开按钮纳入 compact input island,避免点击该按钮触发输入区强制收起。 同步静态 CSS 契约测试到新的常驻 handle 和 controls-collapsed 合约。 验证: - bash build_frontend.sh - .venv/bin/pytest tests/unit/test_react_chat_window_static.py -q - git diff --check * test(chat): align compact history readonly bubble state 同步默认历史面板测试到新的 read-only bubble 语义:操作栏关闭时不暴露按钮角色和 aria-pressed,打开操作栏后再恢复可选择按钮语义。 验证: - npm test -- App.test.tsx - .venv/bin/pytest tests/unit/test_react_chat_window_static.py -q - bash build_frontend.sh - git diff --check * fix(chat): refine compact history handle 将紧凑聊天历史常驻展开按钮下移到聊天框拖拽条附近,并改成与原拖拽条一致的青蓝线条样式。 移除中间小三角视觉,改为一条中间粗、两侧细且渐隐的连续线;不增加背景、边框或阴影,不改变历史面板和展开内容结构。 同步静态 CSS 契约测试,锁定 handle 使用 compact surface top 的轻微上偏移定位。 验证: - .venv/bin/pytest tests/unit/test_react_chat_window_static.py -q - bash build_frontend.sh - git diff --check * fix(chat): soften compact history handle line 优化紧凑聊天历史常驻 handle 的线条样式,参考原聊天框拖拽条的颜色、渐变和 3px 线条厚度。 隐藏中间小三角,改为单条两侧更细、中间保留厚度的平滑线;未展开历史时显示短线,展开后恢复满长度。 不改变历史面板、操作栏、点击区域和展开内容结构;同步静态 CSS 契约测试。 验证: - .venv/bin/pytest tests/unit/test_react_chat_window_static.py -q - bash build_frontend.sh - git diff --check * fix(chat): toggle history handle on press 将紧凑聊天历史常驻 handle 改为 pointer down 即切换打开/关闭,短按和长按都不需要等松开。 按下事件会 preventDefault 并 stopPropagation,避免未来整块聊天框拖拽时长按 handle 被外层拖拽逻辑接管;随后产生的 click 会被吞掉,避免二次切换。 保留键盘和普通 click 兜底路径,并补充 App 测试覆盖按下即切换、松开不反向切换、事件不冒泡。 验证: - npm test -- --run App.test.tsx - .venv/bin/pytest tests/unit/test_react_chat_window_static.py -q - bash build_frontend.sh - git diff --check * fix(chat): allow empty compact input collapse with history 移除 compactExportHistoryOpen 对 collapseCompactInputIfEmpty 的全局早退,避免 first-run 默认显示历史时阻止空输入回到 subtitle。 保留焦点仍在输入壳、历史面板或 history handle 内时不折叠的判断;将 blur 测试改回无持久化记录场景,覆盖默认历史打开状态。 验证: - npm test -- --run App.test.tsx - .venv/bin/pytest tests/unit/test_react_chat_window_static.py -q - bash build_frontend.sh - git diff --check * test(chat): assert history handle visible behavior 将紧凑历史 handle 的按下测试从原生 pointerdown 冒泡细节,改为断言产品可见行为。 按下 handle 后继续验证历史展开、持久化状态写入,并新增 app-shell 仍保持 input 状态的断言,避免测试依赖 DOM 传播实现细节。 验证: - npm test -- --run App.test.tsx - .venv/bin/pytest tests/unit/test_react_chat_window_static.py -q - bash build_frontend.sh - git diff --check
1 parent 93c4562 commit 2866956

16 files changed

Lines changed: 364 additions & 1736 deletions

docs/design/compact-chat-geometry-issue-list.md

Lines changed: 0 additions & 1545 deletions
This file was deleted.

docs/design/home-compact-chat-mode-design.md renamed to docs/design/compact-chat-mode-design.md

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@
5858
- 历史文本拖到角色后发送给当前角色;消息 schema、App、Panel、静态发送桥和去重测试都有对应改动。
5959
7. `8325ed8b` / `6d6a693f`
6060
- 收口 compact interaction、工具恢复、命中、history geometry、测试和样式稳定性。
61+
8. `002407de refine compact history controls`
62+
- 历史显隐改为常驻 handle 控制;工具转轮里的历史/导出按钮改为控制历史操作栏。
63+
9. `e7a8269 stabilize compact resize carrier bounds`
64+
- 桌面端 compact resize 期间锁住 carrier 纵向 bounds,避免历史 extra island 在首次 resize 时触发窗口纵向跳动。
6165

6266
## 当前真实代码链路
6367

@@ -82,15 +86,16 @@
8286
- `.compact-chat-surface-shell`
8387
- `.compact-chat-surface-frame`
8488
- `data-compact-geometry-owner="surface"`
85-
- `data-compact-geometry-item="capsule" | "input" | "dragHandle" | "resizeHandle" | "toolFan" | "history" | "choice"`
89+
- `data-compact-geometry-item="capsule" | "input" | "dragHandle" | "resizeHandle" | "toolFan" | "history" | "historyHandle" | "choice"`
8690
- `data-compact-geometry-part="capsuleBody" | "inputBody"`
8791
6. 早期 `.compact-chat-capsule-shell` / `.compact-chat-input-shell` 已不是当前主体事实,后续文档和实现不要再按这两个旧类名设计。
8892
7. `.compact-chat-surface-frame` 是同一个 54px 高的本体:`default/options` 内放 capsule button,`input` 内放 textarea 和右侧工具/发送按钮。
8993
8. 蓝线拖拽手柄是 `.compact-chat-drag-handle`,带 `data-compact-drag-handle="true"`,必须保留。
9094
9. 左右缩放手柄是 `.compact-chat-resize-handle-left/right`,通过 `neko:compact-surface-resize-request` 与宿主同步宽度。
9195
10. 工具转轮通过 portal 挂到 `document.body`,并以 `data-compact-geometry-item="toolFan"` 进入 geometry。
9296
11. 历史层由 `CompactExportHistoryPanel` 挂载到 `app-shell` 内,锚点是 `.compact-export-history-anchor`,并以 `data-compact-geometry-item="history"` 进入 geometry。
93-
12. ChoicePrompt 和 GalGame options 共享 compact choice layer;ChoicePrompt 优先,GalGame 在无 ChoicePrompt 时显示。
97+
12. 历史显隐由常驻 `.compact-history-visibility-handle` 控制,使用 `data-compact-geometry-item="historyHandle"` 进入 geometry。
98+
13. ChoicePrompt 和 GalGame options 共享 compact choice layer;ChoicePrompt 优先,GalGame 在无 ChoicePrompt 时显示。
9499

95100
### NEKO 宿主与静态桥
96101

@@ -174,7 +179,7 @@
174179
### `default`
175180

176181
1. 显示 `.compact-chat-surface-frame` 胶囊和当前短句。
177-
2. 不显示完整历史
182+
2. 历史层是否显示由独立 history visibility state 控制;初次启动默认显示历史,但历史不属于胶囊本体高度
178183
3. 不展开 textarea。
179184
4. 点击胶囊进入 `input`
180185
5. 语音模式 / `composerHidden` 下,点击胶囊不能请求或恢复 `input`,compact 只保留当前文字展示和已打开的历史显示。
@@ -281,10 +286,11 @@ Compact Interaction Geometry 是紧凑态的根合同。所有可见、可点、
281286
2. `input`
282287
3. `choice`
283288
4. `history`
284-
5. `toolFan`
285-
6. `dragHandle`
286-
7. `resizeHandle`
287-
8. `ball`
289+
5. `historyHandle`
290+
6. `toolFan`
291+
7. `dragHandle`
292+
8. `resizeHandle`
293+
9. `ball`
288294

289295
规则:
290296

@@ -342,7 +348,7 @@ Compact Interaction Geometry 是紧凑态的根合同。所有可见、可点、
342348

343349
## 内联历史与导出合同
344350

345-
Compact 默认不展示完整历史;历史通过工具转轮中的历史/导出入口按需打开
351+
Compact 历史默认在初次启动时显示。历史列表本身由常驻展开/收起 handle 控制;工具转轮中的历史/导出入口不再直接开关历史列表,而是开关历史下方的操作栏
346352

347353
当前已落地事实:
348354

@@ -352,10 +358,16 @@ Compact 默认不展示完整历史;历史通过工具转轮中的历史/导
352358
4. 最新消息在最下方,靠近聊天框。
353359
5. 历史区域有最大高度,超出后内部滚动。
354360
6. 打开历史后,如果用户继续聊天,应自动保持或恢复到底部。
355-
7. 选择状态、导出预览和控件折叠状态由 React state 管理。
356-
8. 预览关闭时要清理 stale export error 和必要 preview lifecycle 状态,避免重新打开显示旧错误。
357-
9. 历史透明区域不能长期遮挡后方;可见气泡、按钮、预览控件和必要滚动区域可命中,气泡间透明区应尽量穿透。
358-
10. GalGame / ChoicePrompt 出现时,选项层在历史层上方。
361+
7. 没有 `neko.reactChatWindow.compactExportHistoryOpen` 持久化记录时,历史默认打开;用户显式收起后持久化为 `false`
362+
8. 常驻 `.compact-history-visibility-handle` 只控制历史列表显隐;它关闭历史时不清除操作栏打开状态。
363+
9. 工具转轮历史/导出按钮控制操作栏显示;如果历史关闭时点击该按钮,应先打开历史并显示操作栏。
364+
10. 操作栏显示期间进入选择模式:气泡点击 / 键盘 Enter / Space 可以选中或取消选中历史消息。
365+
11. 操作栏隐藏时退出选择模式:必须清空当前选中项,并禁止继续通过点击或键盘选择;拖拽源识别和拖拽发送不受这个选择模式限制。
366+
12. 操作栏包含选择和导出动作,如计数、全选、取消/清空、反选、导出预览等;操作栏自身进入 history hit region。
367+
13. 选择状态、导出预览和操作栏显示状态由 React state 管理;操作栏状态可以跨历史显隐保留,但只在历史实际打开时算作可见。
368+
14. 预览关闭时要清理 stale export error 和必要 preview lifecycle 状态,避免重新打开显示旧错误。
369+
15. 历史透明区域不能长期遮挡后方;可见气泡、按钮、预览控件和必要滚动区域可命中,气泡间透明区应尽量穿透。
370+
16. GalGame / ChoicePrompt 出现时,选项层在历史层上方。
359371

360372
## 历史气泡拖拽与发送合同
361373

@@ -512,7 +524,7 @@ Compact 当前文字是“当前轮轻提示”,不是完整历史记录、字
512524
基础形态:
513525

514526
1. `compact ↔ minimized` 切换稳定。
515-
2. compact 默认不展示完整历史
527+
2. compact 初次启动默认展示历史;用户显式收起历史后,下次按持久化记录恢复收起
516528
3. compact 当前文字在下方 surface 内,不出现上方独立说话框。
517529
4. minimized ball 位于模型左侧,且不随 surface 拖拽。
518530

@@ -551,11 +563,14 @@ Surface:
551563

552564
历史:
553565

554-
1. 历史入口只在用户明确打开时显示历史
566+
1. 历史列表初次默认显示;常驻展开/收起 handle 可以显示或隐藏历史列表
555567
2. 历史最新消息在下方并可自动回到底部。
556-
3. 历史选择、全选、反选、清空、导出预览可用。
557-
4. 历史透明区不遮挡后方。
558-
5. 预览关闭不会保留旧 error。
568+
3. 工具转轮历史/导出按钮显示或隐藏操作栏,不直接切换历史列表。
569+
4. 操作栏显示时可以点击/键盘选中历史消息;操作栏隐藏时清空已选消息并禁止继续选择。
570+
5. 操作栏隐藏后再次打开,选择、全选、反选、清空、导出预览可用。
571+
6. 历史列表收起再展开时,操作栏显示状态可保留,但按钮高亮只反映当前实际可见状态。
572+
7. 历史透明区不遮挡后方。
573+
8. 预览关闭不会保留旧 error。
559574

560575
历史拖拽:
561576

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

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ describe('App', () => {
350350
}
351351
});
352352

353-
it('toggles compact inline history from the export tool without calling the full export path', async () => {
353+
it('defaults compact history open and preserves history controls through visibility toggles', async () => {
354354
const onExportConversationClick = vi.fn();
355355
const message = parseChatMessage({
356356
id: 'assistant-history-1',
@@ -371,8 +371,6 @@ describe('App', () => {
371371
/>,
372372
);
373373

374-
const exportButton = await clickCompactExportTool();
375-
376374
expect(onExportConversationClick).not.toHaveBeenCalled();
377375
expect(container.querySelector('.compact-export-history-anchor')).not.toBeNull();
378376
expect(container.querySelector('.compact-export-history-anchor')).toHaveAttribute('data-compact-geometry-hit-scope', 'children');
@@ -381,18 +379,43 @@ describe('App', () => {
381379
expect(container.querySelector('.compact-export-history-bubble')).toHaveAttribute('data-compact-hit-region', 'true');
382380
expect(container.querySelector('.compact-export-history-bubble')).toHaveAttribute('data-compact-hit-region-id', 'history:message:assistant-history-1');
383381
expect(container.querySelector('.compact-export-history-bubble')).toHaveAttribute('data-compact-hit-region-kind', 'message');
384-
expect(container.querySelector('.compact-export-history-controls')).toHaveAttribute('data-compact-hit-region-id', 'history:controls');
382+
expect(container.querySelector('.compact-export-history-controls')).toBeNull();
383+
expect(container.querySelector('.compact-history-visibility-handle')).toHaveAttribute('data-compact-geometry-item', 'historyHandle');
384+
expect(container.querySelector('.compact-history-visibility-handle')).toHaveAttribute('aria-expanded', 'true');
385385
expect(container.querySelector('.compact-export-history-message')).toHaveAttribute('role', 'listitem');
386386
expect(container.querySelector('.compact-export-history-message')).not.toHaveAttribute('aria-pressed');
387+
expect(container.querySelector('.compact-export-history-bubble')).not.toHaveAttribute('role');
388+
expect(container.querySelector('.compact-export-history-bubble')).not.toHaveAttribute('aria-pressed');
389+
expect(container.querySelector('.compact-export-history-bubble')).toHaveAttribute('aria-disabled', 'true');
390+
expect(container.querySelector('.compact-export-history-bubble')).toHaveAttribute('tabindex', '-1');
391+
expect(window.localStorage.getItem(COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY)).toBeNull();
392+
393+
const exportButton = await clickCompactExportTool();
387394
expect(container.querySelector('.compact-export-history-bubble')).toHaveAttribute('role', 'button');
395+
expect(container.querySelector('.compact-export-history-bubble')).toHaveAttribute('aria-pressed', 'false');
396+
expect(container.querySelector('.compact-export-history-bubble')).toHaveAttribute('aria-disabled', 'false');
397+
expect(container.querySelector('.compact-export-history-bubble')).toHaveAttribute('tabindex', '0');
398+
expect(container.querySelector('.compact-export-history-controls')).toHaveAttribute('data-compact-hit-region-id', 'history:controls');
388399
expect(exportButton).toHaveAttribute('aria-pressed', 'true');
389-
expect(window.localStorage.getItem(COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY)).toBe('true');
390400

391-
await clickCompactExportTool();
401+
fireEvent.click(container.querySelector<HTMLButtonElement>('.compact-history-visibility-handle')!);
392402
expect(container.querySelector('.compact-export-history-anchor')).toBeNull();
393403
expect(container.querySelector('[data-compact-hit-region-id^="history:"]')).toBeNull();
404+
expect(container.querySelector('.compact-history-visibility-handle')).toHaveAttribute('aria-expanded', 'false');
394405
expect(exportButton).toHaveAttribute('aria-pressed', 'false');
395406
expect(window.localStorage.getItem(COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY)).toBe('false');
407+
408+
fireEvent.click(container.querySelector<HTMLButtonElement>('.compact-history-visibility-handle')!);
409+
expect(container.querySelector('.compact-export-history-anchor')).not.toBeNull();
410+
expect(container.querySelector('.compact-export-history-controls')).toHaveAttribute('data-compact-hit-region-id', 'history:controls');
411+
expect(container.querySelector('.compact-history-visibility-handle')).toHaveAttribute('aria-expanded', 'true');
412+
expect(exportButton).toHaveAttribute('aria-pressed', 'true');
413+
expect(window.localStorage.getItem(COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY)).toBe('true');
414+
415+
await clickCompactExportTool();
416+
expect(container.querySelector('.compact-export-history-anchor')).not.toBeNull();
417+
expect(container.querySelector('.compact-export-history-controls')).toBeNull();
418+
expect(exportButton).toHaveAttribute('aria-pressed', 'false');
396419
});
397420

398421
it('restores compact inline history from persisted open state after remount', () => {
@@ -412,7 +435,42 @@ describe('App', () => {
412435
);
413436

414437
expect(container.querySelector('.compact-export-history-anchor')).not.toBeNull();
415-
expect(container.querySelector('.compact-input-tool-item-export')).toHaveAttribute('aria-pressed', 'true');
438+
expect(container.querySelector('.compact-export-history-controls')).toBeNull();
439+
expect(container.querySelector('.compact-history-visibility-handle')).toHaveAttribute('aria-expanded', 'true');
440+
expect(container.querySelector('.compact-input-tool-item-export')).toHaveAttribute('aria-pressed', 'false');
441+
});
442+
443+
it('toggles compact history visibility as soon as the handle is pressed', () => {
444+
window.localStorage.setItem(COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY, 'false');
445+
446+
const { container } = render(
447+
<App chatSurfaceMode="compact" compactChatState="input" />,
448+
);
449+
450+
const handle = container.querySelector<HTMLButtonElement>('.compact-history-visibility-handle');
451+
expect(handle).not.toBeNull();
452+
expect(handle).toHaveAttribute('aria-expanded', 'false');
453+
expect(container.querySelector('.compact-export-history-anchor')).toBeNull();
454+
455+
fireEvent.pointerDown(handle!, { pointerType: 'mouse', button: 0 });
456+
expect(handle).toHaveAttribute('aria-expanded', 'true');
457+
expect(container.querySelector('.compact-export-history-anchor')).not.toBeNull();
458+
expect(window.localStorage.getItem(COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY)).toBe('true');
459+
expect(container.querySelector('.app-shell')).toHaveAttribute('data-compact-chat-state', 'input');
460+
461+
fireEvent.click(handle!);
462+
expect(handle).toHaveAttribute('aria-expanded', 'true');
463+
464+
fireEvent.pointerDown(handle!, { pointerType: 'mouse', button: 0 });
465+
expect(handle).toHaveAttribute('aria-expanded', 'false');
466+
expect(container.querySelector('.compact-export-history-anchor')).toBeNull();
467+
expect(window.localStorage.getItem(COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY)).toBe('false');
468+
469+
fireEvent.click(handle!);
470+
expect(handle).toHaveAttribute('aria-expanded', 'false');
471+
472+
fireEvent.click(handle!);
473+
expect(handle).toHaveAttribute('aria-expanded', 'true');
416474
});
417475

418476
it('keeps compact export history message actions read-only', async () => {
@@ -582,10 +640,10 @@ describe('App', () => {
582640
const anchor = container.querySelector('.compact-export-history-anchor');
583641
expect(anchor).not.toHaveClass('controls-collapsed');
584642

585-
fireEvent.click(container.querySelector<HTMLButtonElement>('.compact-export-history-controls-toggle')!);
643+
await clickCompactExportTool();
586644
expect(anchor).toHaveClass('controls-collapsed');
587645

588-
fireEvent.click(container.querySelector<HTMLButtonElement>('.compact-export-history-controls-toggle')!);
646+
await clickCompactExportTool();
589647
expect(anchor).not.toHaveClass('controls-collapsed');
590648
});
591649

@@ -627,11 +685,18 @@ describe('App', () => {
627685
<App chatSurfaceMode="compact" compactChatState="input" messages={[assistantMessage, userMessage]} />,
628686
);
629687

630-
await clickCompactExportTool();
631688
const messages = container.querySelectorAll<HTMLElement>('.compact-export-history-message');
632689
const bubbles = container.querySelectorAll<HTMLElement>('.compact-export-history-bubble');
633690
fireEvent.click(bubbles[1]);
634691

692+
expect(messages[1]).not.toHaveClass('is-selected');
693+
await clickCompactExportTool();
694+
fireEvent.click(bubbles[1]);
695+
expect(messages[1]).toHaveClass('is-selected');
696+
await clickCompactExportTool();
697+
expect(messages[1]).not.toHaveClass('is-selected');
698+
await clickCompactExportTool();
699+
fireEvent.click(bubbles[1]);
635700
expect(messages[1]).toHaveClass('is-selected');
636701
fireEvent.click(container.querySelector<HTMLButtonElement>('.compact-export-history-export')!);
637702

@@ -1757,7 +1822,7 @@ describe('App', () => {
17571822

17581823
expect(container.querySelector('.compact-export-history-anchor')).toBeNull();
17591824
expect(container.querySelector('[data-compact-hit-region-id^="history:"]')).toBeNull();
1760-
expect(window.localStorage.getItem(COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY)).toBe('true');
1825+
expect(window.localStorage.getItem(COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY)).toBeNull();
17611826

17621827
rerender(<App chatSurfaceMode="compact" compactChatState="input" messages={[message]} />);
17631828

0 commit comments

Comments
 (0)