fix(ui): restore IME Enter protection in workspace details

This commit is contained in:
setodeve
2026-09-30 18:18:40 +09:00
parent 444e0b1cf9
commit b30f095eb6
5 changed files with 176 additions and 13 deletions
@@ -4,6 +4,10 @@ import { Label } from '@/components/ui/label'
import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover'
import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput'
import { translate } from '@/i18n/i18n'
import {
isImeOwnedKeyboardEvent,
useImeEnterGestureOwnership
} from '@/lib/ime-composition-keyboard-event'
type WorktreeDisplayNameFieldProps = {
disabled: boolean
@@ -23,6 +27,7 @@ export function WorktreeDisplayNameField({
value
}: WorktreeDisplayNameFieldProps): React.JSX.Element {
const inputId = useId()
const enterGesture = useImeEnterGestureOwnership()
const emojiInput = useWorkspaceEmojiShortcodeInput({
disabled,
inputRef,
@@ -42,9 +47,19 @@ export function WorktreeDisplayNameField({
onChange={(event) =>
emojiInput.handleValueChange(event.target.value, event.target.selectionStart)
}
onCompositionStart={() => enterGesture.setComposing(true)}
onCompositionEnd={() => enterGesture.setComposing(false)}
onKeyUp={enterGesture.onKeyUp}
onBlur={enterGesture.reset}
onSelect={(event) => emojiInput.syncCursor(event.currentTarget)}
onKeyDown={(event) => {
if (emojiInput.handleKeyDown(event) || event.key !== 'Enter') {
if (
enterGesture.ownsKeyDown(event) ||
enterGesture.isComposing() ||
isImeOwnedKeyboardEvent(event) ||
emojiInput.handleKeyDown(event) ||
event.key !== 'Enter'
) {
return
}
event.preventDefault()
@@ -1,6 +1,10 @@
import React, { useCallback, useId, useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
isImeOwnedKeyboardEvent,
useImeEnterGestureOwnership
} from '@/lib/ime-composition-keyboard-event'
import {
DropdownMenu,
DropdownMenuContent,
@@ -88,6 +92,7 @@ export function WorktreeIssueLinkField(props: WorktreeIssueLinkFieldProps): Reac
onKeyDown
} = props
const enterGesture = useImeEnterGestureOwnership()
// Why: the helper `<p>` is the only surface for the invalid and displacement
// states, so it has to be announced with the field rather than just seen.
const helperId = useId()
@@ -172,7 +177,20 @@ export function WorktreeIssueLinkField(props: WorktreeIssueLinkFieldProps): Reac
aria-describedby={helperId}
value={value}
onChange={(e) => onValueChange(e.target.value)}
onKeyDown={onKeyDown}
onCompositionStart={() => enterGesture.setComposing(true)}
onCompositionEnd={() => enterGesture.setComposing(false)}
onKeyUp={enterGesture.onKeyUp}
onBlur={enterGesture.reset}
onKeyDown={(event) => {
if (
enterGesture.ownsKeyDown(event) ||
enterGesture.isComposing() ||
isImeOwnedKeyboardEvent(event)
) {
return
}
onKeyDown(event)
}}
disabled={isReadOnly}
aria-invalid={isInvalid || undefined}
placeholder={translate(
@@ -223,9 +223,112 @@ describe('WorktreeMetaDialog issue link row', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
useAppStore.setState(initialState, true)
})
it.each([
'Notes about this worktree...',
'Custom display name...',
'Issue #, or a GitHub or Linear URL',
'PR # or GitHub URL'
])('ignores IME Enter and preserves ordinary Enter in %s', async (placeholder) => {
openDialog()
const input = screen.getByPlaceholderText(placeholder)
for (const marker of [{ isComposing: true, keyCode: 13 }, { keyCode: 229 }]) {
expect(fireEvent.keyDown(input, { key: 'Enter', ...marker })).toBe(true)
expect(updateWorktreeMeta).not.toHaveBeenCalled()
expect(useAppStore.getState().activeModal).toBe('edit-meta')
}
fireEvent.blur(input)
await act(async () => {
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 })
})
expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)
expect(useAppStore.getState().activeModal).toBe('none')
})
it.each(
['before keyup', 'after keyup'].flatMap((order) =>
[
{
placeholder: 'Notes about this worktree...',
value: '日本語のノート',
updates: { comment: '日本語のノート' }
},
{
placeholder: 'Custom display name...',
value: '日本語の名前',
updates: { displayName: '日本語の名前' }
},
{
placeholder: 'Issue #, or a GitHub or Linear URL',
value: '42',
updates: { linkedIssue: 42 }
},
{ placeholder: 'PR # or GitHub URL', value: '43', updates: { linkedPR: 43 } },
{ placeholder: 'MR ! or GitLab URL', value: '!44', updates: { linkedGitLabMR: 44 } }
].map((field) => ({ ...field, order }))
)
)(
'ignores the IME Enter redispatch $order in $placeholder',
async ({ order, placeholder, value, updates }) => {
openDialog(placeholder === 'MR ! or GitLab URL' ? { modalReviewProvider: 'gitlab' } : {})
const input = screen.getByPlaceholderText(placeholder)
const frames: FrameRequestCallback[] = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) =>
frames.push(callback)
)
fireEvent.compositionStart(input)
fireEvent.keyDown(input, { key: 'Process', keyCode: 229, isComposing: true })
fireEvent.change(input, { target: { value } })
fireEvent.compositionEnd(input)
if (order === 'after keyup') {
fireEvent.keyUp(input, { key: 'Enter', keyCode: 13 })
}
expect(fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 })).toBe(false)
expect(updateWorktreeMeta).not.toHaveBeenCalled()
expect(useAppStore.getState().activeModal).toBe('edit-meta')
fireEvent.keyUp(input, { key: 'Enter', keyCode: 13 })
act(() => frames.forEach((callback) => callback(0)))
await act(async () => {
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 })
})
expect(updateWorktreeMeta.mock.calls[0]?.[1]).toEqual(expect.objectContaining(updates))
expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)
expect(useAppStore.getState().activeModal).toBe('none')
}
)
it.each([
{ userAgent: 'Macintosh', modifier: { metaKey: true } },
{ userAgent: 'Windows', modifier: { ctrlKey: true } },
{ userAgent: 'Linux', modifier: { ctrlKey: true } }
])(
'keeps the save shortcut outside composition on $userAgent',
async ({ userAgent, modifier }) => {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent)
openDialog()
const input = screen.getByPlaceholderText('Notes about this worktree...')
fireEvent.compositionStart(input)
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, ...modifier })
expect(updateWorktreeMeta).not.toHaveBeenCalled()
fireEvent.compositionEnd(input)
await act(async () => {
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, ...modifier })
})
expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)
}
)
it('leaves Shift+Enter available for a newline', () => {
openDialog()
const input = screen.getByPlaceholderText('Notes about this worktree...')
expect(fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, shiftKey: true })).toBe(true)
expect(updateWorktreeMeta).not.toHaveBeenCalled()
expect(useAppStore.getState().activeModal).toBe('edit-meta')
})
it('seeds the chip and value from a GitHub link', () => {
openDialog({ worktree: { linkedIssue: 42 } })
@@ -34,6 +34,10 @@ import {
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { WorktreeDisplayNameField } from './WorktreeDisplayNameField'
import { WorktreeReviewLinkField } from './WorktreeReviewLinkField'
import {
isImeOwnedKeyboardEvent,
useImeEnterGestureOwnership
} from '@/lib/ime-composition-keyboard-event'
function resizeCommentTextarea(textarea: HTMLTextAreaElement): void {
textarea.style.height = 'auto'
@@ -55,6 +59,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const closeModal = useAppStore((s) => s.closeModal)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const submitShortcutLabel = getScreenSubmitShortcutLabel()
const commentIme = useImeEnterGestureOwnership()
const isEditMeta = activeModal === 'edit-meta'
const isOpen = isEditMeta
@@ -290,6 +295,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const handleCommentKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (commentIme.ownsKeyDown(e) || commentIme.isComposing() || isImeOwnedKeyboardEvent(e)) {
return
}
const isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey
if (isPlainEnter || isScreenSubmitShortcut(e)) {
e.preventDefault()
@@ -297,18 +305,15 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
handleSave()
}
},
[handleSave]
[commentIme, handleSave]
)
const handleIssueKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSave()
}
},
[handleSave]
)
const handleIssueKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
e.preventDefault()
handleSave()
}
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
@@ -390,6 +395,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
ref={setCommentTextareaRef}
value={commentInput}
onChange={handleCommentChange}
onCompositionStart={() => commentIme.setComposing(true)}
onCompositionEnd={() => commentIme.setComposing(false)}
onKeyUp={commentIme.onKeyUp}
onBlur={commentIme.reset}
onKeyDown={handleCommentKeyDown}
placeholder={translate(
'auto.components.sidebar.WorktreeMetaDialog.030d484fc0',
@@ -1,5 +1,9 @@
import type { RefObject } from 'react'
import { Input } from '@/components/ui/input'
import {
isImeOwnedKeyboardEvent,
useImeEnterGestureOwnership
} from '@/lib/ime-composition-keyboard-event'
import { translate } from '@/i18n/i18n'
import type { WorktreeReviewProvider } from './worktree-meta-updates'
@@ -18,6 +22,7 @@ export function WorktreeReviewLinkField({
provider,
value
}: WorktreeReviewLinkFieldProps): React.JSX.Element {
const enterGesture = useImeEnterGestureOwnership()
const isGitLab = provider === 'gitlab'
return (
<div className="space-y-1">
@@ -30,7 +35,20 @@ export function WorktreeReviewLinkField({
ref={inputRef}
value={value}
onChange={(event) => onValueChange(event.target.value)}
onKeyDown={onKeyDown}
onCompositionStart={() => enterGesture.setComposing(true)}
onCompositionEnd={() => enterGesture.setComposing(false)}
onKeyUp={enterGesture.onKeyUp}
onBlur={enterGesture.reset}
onKeyDown={(event) => {
if (
enterGesture.ownsKeyDown(event) ||
enterGesture.isComposing() ||
isImeOwnedKeyboardEvent(event)
) {
return
}
onKeyDown(event)
}}
placeholder={
isGitLab
? translate(