Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions docs/specs/mobile-terminal-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,6 @@ Source of truth: `MOBILE_GESTURE_OPTION_DIRECTIONS` in `lib/src/lib/mobile-gestu

## Input mode selector

The input mode selector controls what appears in the reserve area.

| Mode | Reserve area content |
| --- | --- |
| Sessions | Session rows with active, alert, and TODO state; selecting one makes it the single visible terminal. |
Expand All @@ -98,9 +96,7 @@ Source of truth: `KEYBOARD_MODES` in `lib/src/components/MobileTerminalUi.tsx`.

## Type mode input

Typing goes through a visually hidden `<textarea>` configured for terminal-style input.

* **Must buffer composed text until `compositionend` and leave composing keydowns to the IME**, including its editing and confirmation keys.
* **Must buffer composed text until `compositionend` and leave composing keydowns to the IME**, including its editing and confirmation keys. **Must cancel composed input when the active Session changes**, including a switch back.
* **Must handle software-keyboard Enter and Backspace through `beforeinput` when no `keydown` occurs**, including deletion from the empty hidden input.

Source of truth: `MobileTerminalUi` in `lib/src/components/MobileTerminalUi.tsx`; `isComposingKey` in `lib/src/lib/dom.ts`.
Expand Down
41 changes: 41 additions & 0 deletions lib/src/components/MobileTerminalUi.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,47 @@ describe('MobileTerminalUi keyboard input', () => {
expect(input.value).toBe('');
});

it.each<Partial<MobileTerminalUiProps>>([{ activeTouchMode: 'selection' }, { interactive: false }])('keeps composition when only UI availability changes: %j', (change) => {
const onSendInput = vi.fn();
const props = { sessions: [{ id: 'same', title: 'same', active: true, episode: null }], onSendInput };
const { input, renderWith } = renderUi(props);
act(() => {
input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
setNativeFieldValue(input, '\u65e5\u672c');
});
renderWith({ ...props, ...change });
renderWith(props);
act(() => input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '\u65e5\u672c' })));
expect(onSendInput.mock.calls).toEqual([['\u65e5\u672c']]);
});

it.each(['switch', 'remove', 'return'])('drops composed text after its Session changes: %s', (transition) => {
const originalInput = vi.fn();
const replacementInput = vi.fn();
const session = { id: 'original', title: 'original', active: true, episode: null };
const props = { sessions: [session], onSendInput: originalInput };
const { input, container, renderWith } = renderUi(props);
act(() => {
input.focus();
input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
setNativeFieldValue(input, '\u65e5\u672c');
});
renderWith({ sessions: transition === 'remove' ? [] : [{ ...session, id: 'replacement' }], onSendInput: replacementInput });
if (transition === 'return') renderWith({ ...props, onSendInput: replacementInput });
act(() => input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '\u65e5\u672c' })));
expect(originalInput).not.toHaveBeenCalled();
expect(replacementInput).not.toHaveBeenCalled();
expect(input.value).toBe('');
expect(container.querySelector('textarea')).toBe(input);
expect(document.activeElement).toBe(input);
act(() => {
input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
setNativeFieldValue(input, 'new');
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: 'new' }));
});
expect(replacementInput.mock.calls).toEqual([['new']]);
});

it.each([{ isComposing: true }, { keyCode: 229 }])('ignores IME confirmation keydown with %j', (imeState) => {
const onSendInput = vi.fn();
const { input } = renderUi({ onSendInput });
Expand Down
16 changes: 12 additions & 4 deletions lib/src/components/MobileTerminalUi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ export function MobileTerminalUi({
const activeSessionId = sessions.find((session) => session.active)?.id;
const terminalHostRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const composingRef = useRef(false);
const composingRef = useRef<{ cancelled: boolean } | null>(null);
const edgeScrollRef = useRef<{ pointerId: number; sessionId: string; motion: EdgeScrollMotion } | null>(null);
const edgeMomentumFrameRef = useRef<number | null>(null);
const gestureStateRef = useRef<MobileGestureTrackingState>(MOBILE_GESTURE_IDLE_STATE);
Expand Down Expand Up @@ -820,6 +820,12 @@ export function MobileTerminalUi({
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, [stopEdgeScroll]);

useEffect(() => {
// Keep the textarea mounted so cancelling a composition does not dismiss
// the keyboard. Touch-mode changes leave its input ownership unchanged.
if (composingRef.current) composingRef.current.cancelled = true;
Comment thread
dormouse-bot marked this conversation as resolved.
}, [activeSessionId]);

useEffect(() => {
// Input callbacks follow the active Session; a gesture or confirmation
// begun on the previous one must never send to its replacement.
Expand Down Expand Up @@ -1136,11 +1142,13 @@ export function MobileTerminalUi({
setInputValue('');
}}
onCompositionStart={() => {
composingRef.current = true;
composingRef.current = { cancelled: false };
}}
onCompositionEnd={(event) => {
composingRef.current = false;
flushInputValue(event.currentTarget.value);
const cancelled = composingRef.current?.cancelled ?? true;
composingRef.current = null;
if (cancelled) setInputValue('');
else flushInputValue(event.currentTarget.value);
}}
onChange={(event) => {
const value = event.currentTarget.value;
Expand Down
Loading