Skip to content

Commit d8d36dd

Browse files
yiyiyiyiGKYHongzhi Wen
andauthored
fix(chat): improve compact input continuity and history scrollbar interaction (#1660)
* fix: keep compact chat input open after send Keep compact text input in input mode after submitting a message so users can continue typing without reopening the composer. Add a regression test covering the controlled compact chat state path used by the desktop host. * fix(chat): make compact history scrollbar interactive 实现方案 B:历史区域透明主体继续穿透,仅在滚动条可见时导出右侧窄条命中区。 新增桌面 hover 驱动的滚动条显示状态,补齐右侧滚动条拖拽/滚轮交互,并避免 hover active 时被隐藏计时器误收起。 补充组件测试和静态契约,确保隐藏状态不导出命中区、under-choice 禁用态不阻挡穿透、pointer capture 释放更稳。 * fix(chat): refocus compact input after submit 修复 compact 文本输入连续发送时的真实焦点问题。 发送按钮提交后,如果仍处于 compact input 且焦点还在输入区域内,下一帧将焦点恢复到 textarea 并把光标放到末尾,避免用户发送一句后还要再点输入框。 更新连续输入回归测试,模拟发送按钮获得焦点后验证提交完成会回到输入框。 * fix(chat): disable text history bubble delivery - 文本历史气泡拖到模型上改为回弹动画,不再发送消息 - 补充连续输入 refocus 后光标停在末尾的测试断言 - 移除历史滚动条隐藏重置的重复 effect * fix(chat): hide inert history scrollbar hit area - 历史不可滚动时不渲染透明滚动条命中层,避免吞掉气泡右侧点击 - 增加短历史 hover 场景回归测试 - 保留可滚动历史的滚动条拖拽交互 * fix(chat): respect disabled history scrollbar geometry - 桌面 geometry 遇到 pointer-events none 的历史滚动条 hit 层时直接跳过 - 避免选项层覆盖历史时右侧 gutter 被重新发布为可交互区域 - 更新 React chat 静态合同测试 --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com>
1 parent 32322f4 commit d8d36dd

7 files changed

Lines changed: 448 additions & 33 deletions

File tree

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

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,7 +1279,7 @@ describe('App', () => {
12791279
expect(message).not.toHaveClass('is-selected');
12801280
});
12811281

1282-
it('sends a compact history text bubble when dropped on the avatar range', async () => {
1282+
it('returns compact history text bubble drops on the avatar range without sending', async () => {
12831283
const cleanupAvatar = setupAvatarDropBounds();
12841284
const onCompactHistoryDrop = vi.fn();
12851285
const textMessage = parseChatMessage({
@@ -1333,24 +1333,19 @@ describe('App', () => {
13331333
pointerType: 'mouse',
13341334
});
13351335

1336+
expect(onCompactHistoryDrop).not.toHaveBeenCalled();
1337+
expect(message).not.toHaveClass('is-selected');
13361338
await waitFor(() => {
1337-
expect(onCompactHistoryDrop).toHaveBeenCalledTimes(1);
1339+
expect(document.body.querySelector('[data-compact-drag-layer="true"]')).toHaveAttribute('data-compact-drag-phase', 'returning');
13381340
});
1339-
expect(onCompactHistoryDrop).toHaveBeenCalledWith(expect.objectContaining({
1340-
text: 'Send this memory again.',
1341-
images: [],
1342-
sourceMessageId: 'assistant-history-drop-text',
1343-
dragType: 'bubble',
1344-
}));
1345-
expect(message).not.toHaveClass('is-selected');
1346-
expect(document.body.querySelector('[data-compact-drag-layer="true"]')).toHaveAttribute('data-compact-drag-phase', 'sending');
13471341
await waitForCompactHistoryDragLayerToClear();
1342+
expect(onCompactHistoryDrop).not.toHaveBeenCalled();
13481343
} finally {
13491344
cleanupAvatar();
13501345
}
13511346
});
13521347

1353-
it('uses desktop avatar bounds for compact history drops in the Electron host', async () => {
1348+
it('uses desktop avatar bounds for returning compact history text bubble drops without sending', async () => {
13541349
const cleanupAvatar = setupDesktopAvatarDropBounds();
13551350
const onCompactHistoryDrop = vi.fn();
13561351
const textMessage = parseChatMessage({
@@ -1402,20 +1397,18 @@ describe('App', () => {
14021397
pointerType: 'mouse',
14031398
});
14041399

1400+
expect(onCompactHistoryDrop).not.toHaveBeenCalled();
14051401
await waitFor(() => {
1406-
expect(onCompactHistoryDrop).toHaveBeenCalledTimes(1);
1402+
expect(document.body.querySelector('[data-compact-drag-layer="true"]')).toHaveAttribute('data-compact-drag-phase', 'returning');
14071403
});
1408-
expect(onCompactHistoryDrop).toHaveBeenCalledWith(expect.objectContaining({
1409-
text: 'Send this desktop memory.',
1410-
sourceMessageId: 'assistant-history-desktop-drop-text',
1411-
}));
14121404
await waitForCompactHistoryDragLayerToClear();
1405+
expect(onCompactHistoryDrop).not.toHaveBeenCalled();
14131406
} finally {
14141407
cleanupAvatar();
14151408
}
14161409
});
14171410

1418-
it('accepts desktop compact history drag target feedback from NEKO-PC', async () => {
1411+
it('accepts desktop compact history drag target feedback from NEKO-PC while returning text bubbles', async () => {
14191412
const onCompactHistoryDrop = vi.fn();
14201413
const onCompactHistoryDragStateChange = vi.fn();
14211414
const textMessage = parseChatMessage({
@@ -1504,14 +1497,12 @@ describe('App', () => {
15041497
pointerType: 'mouse',
15051498
});
15061499

1500+
expect(onCompactHistoryDrop).not.toHaveBeenCalled();
15071501
await waitFor(() => {
1508-
expect(onCompactHistoryDrop).toHaveBeenCalledTimes(1);
1502+
expect(document.body.querySelector('[data-compact-drag-layer="true"]')).toHaveAttribute('data-compact-drag-phase', 'returning');
15091503
});
1510-
expect(onCompactHistoryDrop).toHaveBeenCalledWith(expect.objectContaining({
1511-
text: 'Send through desktop feedback.',
1512-
sourceMessageId: 'assistant-history-desktop-feedback-drop',
1513-
}));
15141504
await waitForCompactHistoryDragLayerToClear();
1505+
expect(onCompactHistoryDrop).not.toHaveBeenCalled();
15151506
});
15161507

15171508
it('does not send a compact history drag released outside the avatar range', async () => {
@@ -5769,6 +5760,41 @@ describe('App', () => {
57695760
expect(onComposerSubmit).toHaveBeenCalledWith({ text: 'Test compact send' });
57705761
});
57715762

5763+
it('keeps controlled compact input focused after submitting text for continuous typing', async () => {
5764+
const onComposerSubmit = vi.fn();
5765+
5766+
function CompactContinuousInputHarness() {
5767+
const [compactChatState, setCompactChatState] = useState<CompactChatState>('input');
5768+
return (
5769+
<App
5770+
chatSurfaceMode="compact"
5771+
compactChatState={compactChatState}
5772+
onCompactChatStateChange={setCompactChatState}
5773+
onComposerSubmit={onComposerSubmit}
5774+
/>
5775+
);
5776+
}
5777+
5778+
const { container } = render(<CompactContinuousInputHarness />);
5779+
5780+
const input = screen.getByPlaceholderText('Type a message...');
5781+
fireEvent.change(input, { target: { value: 'First compact message' } });
5782+
const sendButton = screen.getByRole('button', { name: 'Send' });
5783+
sendButton.focus();
5784+
expect(document.activeElement).toBe(sendButton);
5785+
fireEvent.click(sendButton);
5786+
5787+
expect(onComposerSubmit).toHaveBeenCalledWith({ text: 'First compact message' });
5788+
expect(container.querySelector('.app-shell')).toHaveAttribute('data-compact-chat-state', 'input');
5789+
expect(screen.getByPlaceholderText('Type a message...')).toHaveValue('');
5790+
await waitFor(() => {
5791+
expect(document.activeElement).toBe(screen.getByPlaceholderText('Type a message...'));
5792+
});
5793+
const refocusedInput = screen.getByPlaceholderText('Type a message...') as HTMLTextAreaElement;
5794+
expect(refocusedInput.selectionStart).toBe(refocusedInput.value.length);
5795+
expect(refocusedInput.selectionEnd).toBe(refocusedInput.value.length);
5796+
});
5797+
57725798
it('returns empty compact input to subtitle state when it loses focus', async () => {
57735799
const onCompactChatStateChange = vi.fn();
57745800
const outsideButton = document.createElement('button');

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4415,20 +4415,42 @@ export default function App({
44154415
}
44164416
}
44174417

4418+
function focusCompactInputForContinuousTyping() {
4419+
const inputNode = compactInputRef.current;
4420+
if (!inputNode || inputNode.disabled || inputNode.readOnly) return;
4421+
const activeElement = document.activeElement;
4422+
if (
4423+
activeElement instanceof Node
4424+
&& compactInputShellRef.current
4425+
&& !compactInputShellRef.current.contains(activeElement)
4426+
) return;
4427+
inputNode.focus();
4428+
const selectionEnd = inputNode.value.length;
4429+
inputNode.setSelectionRange(selectionEnd, selectionEnd);
4430+
}
4431+
44184432
function submitDraft() {
44194433
if (composerDisabled) return;
44204434
if (submittingRef.current) return;
44214435
const text = draft.trim();
44224436
if (!text && composerAttachments.length === 0) return;
44234437
closeCompactInputToolFan();
44244438
submittingRef.current = true;
4439+
let shouldRefocusCompactInput = false;
44254440
try {
44264441
onComposerSubmit?.({ text });
44274442
setDraft('');
44284443
restoreCompactExportHistoryToBottomForOutgoingMessage();
4429-
requestCompactChatState('default');
4444+
shouldRefocusCompactInput = isCompactSurface
4445+
&& effectiveCompactChatState === 'input'
4446+
&& text.length > 0;
44304447
} finally {
4431-
requestAnimationFrame(() => { submittingRef.current = false; });
4448+
requestAnimationFrame(() => {
4449+
submittingRef.current = false;
4450+
if (shouldRefocusCompactInput) {
4451+
focusCompactInputForContinuousTyping();
4452+
}
4453+
});
44324454
}
44334455
}
44344456

@@ -4481,6 +4503,9 @@ export default function App({
44814503
return false;
44824504
}
44834505

4506+
if (request.payload.type === 'bubble' && hasText && !hasImages) {
4507+
return false;
4508+
}
44844509
restoreCompactExportHistoryToBottomForOutgoingMessage();
44854510
if (onCompactHistoryDrop) {
44864511
return normalizeCompactHistoryDropResult(onCompactHistoryDrop(payload));

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

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
1+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
22
import CompactExportHistoryPanel from './CompactExportHistoryPanel';
33
import { parseChatMessage } from './message-schema';
44

@@ -98,6 +98,183 @@ describe('CompactExportHistoryPanel', () => {
9898
}
9999
});
100100

101+
it('shows the compact history scrollbar while the desktop cursor is over the history area', () => {
102+
const { container } = renderPanel({
103+
previewOpen: false,
104+
visibilityState: 'open',
105+
});
106+
107+
const scroll = container.querySelector('.compact-export-history-scroll');
108+
expect(scroll).not.toBeNull();
109+
expect(scroll).not.toHaveAttribute('data-compact-scrollbar-visible');
110+
111+
fireEvent(window, new CustomEvent('neko:compact-history-hover-state-change', {
112+
detail: { active: true },
113+
}));
114+
expect(scroll).toHaveAttribute('data-compact-scrollbar-visible', 'true');
115+
116+
fireEvent(window, new CustomEvent('neko:compact-history-hover-state-change', {
117+
detail: { active: false },
118+
}));
119+
expect(scroll).not.toHaveAttribute('data-compact-scrollbar-visible');
120+
});
121+
122+
it('keeps the scrollbar visible while the desktop cursor remains over transparent history', () => {
123+
vi.useFakeTimers();
124+
125+
try {
126+
const { container } = renderPanel({
127+
previewOpen: false,
128+
visibilityState: 'open',
129+
});
130+
131+
const scroll = container.querySelector('.compact-export-history-scroll');
132+
expect(scroll).not.toBeNull();
133+
134+
fireEvent(window, new CustomEvent('neko:compact-history-hover-state-change', {
135+
detail: { active: true },
136+
}));
137+
expect(scroll).toHaveAttribute('data-compact-scrollbar-visible', 'true');
138+
139+
fireEvent.wheel(scroll!, { deltaY: 12 });
140+
act(() => {
141+
vi.advanceTimersByTime(1200);
142+
});
143+
expect(scroll).toHaveAttribute('data-compact-scrollbar-visible', 'true');
144+
145+
fireEvent(window, new CustomEvent('neko:compact-history-hover-state-change', {
146+
detail: { active: false },
147+
}));
148+
expect(scroll).not.toHaveAttribute('data-compact-scrollbar-visible');
149+
} finally {
150+
vi.useRealTimers();
151+
}
152+
});
153+
154+
it('does not render the scrollbar hit area when the history cannot scroll', () => {
155+
const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight');
156+
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight');
157+
158+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
159+
configurable: true,
160+
get() {
161+
return this.classList.contains('compact-export-history-scroll') ? 240 : 0;
162+
},
163+
});
164+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
165+
configurable: true,
166+
get() {
167+
return this.classList.contains('compact-export-history-scroll') ? 240 : 0;
168+
},
169+
});
170+
171+
try {
172+
const { container } = renderPanel({
173+
previewOpen: false,
174+
visibilityState: 'open',
175+
});
176+
177+
const scroll = container.querySelector('.compact-export-history-scroll');
178+
expect(scroll).not.toBeNull();
179+
180+
fireEvent(window, new CustomEvent('neko:compact-history-hover-state-change', {
181+
detail: { active: true },
182+
}));
183+
expect(scroll).toHaveAttribute('data-compact-scrollbar-visible', 'true');
184+
expect(container.querySelector('.compact-export-history-scrollbar-hit')).toBeNull();
185+
} finally {
186+
if (scrollHeightDescriptor) {
187+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', scrollHeightDescriptor);
188+
} else {
189+
Reflect.deleteProperty(HTMLElement.prototype, 'scrollHeight');
190+
}
191+
if (clientHeightDescriptor) {
192+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', clientHeightDescriptor);
193+
} else {
194+
Reflect.deleteProperty(HTMLElement.prototype, 'clientHeight');
195+
}
196+
}
197+
});
198+
199+
it('scrolls the compact history list when the visible scrollbar hit area is dragged', () => {
200+
const scrollTopByElement = new WeakMap<HTMLElement, number>();
201+
const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight');
202+
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight');
203+
const scrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop');
204+
205+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
206+
configurable: true,
207+
get() {
208+
return this.classList.contains('compact-export-history-scroll') ? 1000 : 0;
209+
},
210+
});
211+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
212+
configurable: true,
213+
get() {
214+
return this.classList.contains('compact-export-history-scroll') ? 250 : 0;
215+
},
216+
});
217+
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
218+
configurable: true,
219+
get() {
220+
return scrollTopByElement.get(this) ?? 0;
221+
},
222+
set(value: number) {
223+
scrollTopByElement.set(this, value);
224+
},
225+
});
226+
227+
try {
228+
const { container } = renderPanel({
229+
previewOpen: false,
230+
visibilityState: 'open',
231+
});
232+
const scroll = container.querySelector<HTMLElement>('.compact-export-history-scroll');
233+
expect(scroll).not.toBeNull();
234+
235+
fireEvent(window, new CustomEvent('neko:compact-history-hover-state-change', {
236+
detail: { active: true },
237+
}));
238+
const hit = container.querySelector<HTMLElement>('.compact-export-history-scrollbar-hit');
239+
expect(hit).not.toBeNull();
240+
241+
fireEvent.pointerDown(hit!, {
242+
pointerId: 1,
243+
pointerType: 'mouse',
244+
button: 0,
245+
clientY: 20,
246+
});
247+
fireEvent.pointerMove(hit!, {
248+
pointerId: 1,
249+
pointerType: 'mouse',
250+
clientY: 70,
251+
});
252+
fireEvent.pointerUp(hit!, {
253+
pointerId: 1,
254+
pointerType: 'mouse',
255+
clientY: 70,
256+
});
257+
258+
expect(scroll!.scrollTop).toBeGreaterThan(0);
259+
} finally {
260+
if (scrollHeightDescriptor) {
261+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', scrollHeightDescriptor);
262+
} else {
263+
Reflect.deleteProperty(HTMLElement.prototype, 'scrollHeight');
264+
}
265+
if (clientHeightDescriptor) {
266+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', clientHeightDescriptor);
267+
} else {
268+
Reflect.deleteProperty(HTMLElement.prototype, 'clientHeight');
269+
}
270+
if (scrollTopDescriptor) {
271+
Object.defineProperty(HTMLElement.prototype, 'scrollTop', scrollTopDescriptor);
272+
} else {
273+
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTop');
274+
}
275+
}
276+
});
277+
101278
it('handles synchronous preview build failures in the preview error state', async () => {
102279
renderPanel({
103280
onBuildPreview: vi.fn(() => {

0 commit comments

Comments
 (0)