fix(mobile): buzz the quick-command row when a copy is refused

The last of the seven migrated writes without the error haptic. The row already
said "Couldn't copy" on its own control, in red, for the 1500 ms the toast the
other six show would have lasted, so it never claimed a refused write had landed;
what it had no way to say was anything the thumb still on the button could feel.

Its first test, on the harness its list already uses: the seam rejects when the
pasteboard refuses, and the two cases are the difference between the row that
shows a green check over nothing copied and the row that does not. The list's own
test gains the haptics mock the row's new import needs.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 08:33:33 -04:00
parent 6418507171
commit 02cbb52eee
3 changed files with 134 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import { QuickCommandRow } from './QuickCommandRow'
const clipboard = vi.hoisted(() => ({ setStringAsync: vi.fn(() => Promise.resolve(true)) }))
const haptics = vi.hoisted(() => ({ notificationAsync: vi.fn(() => Promise.resolve()) }))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
Pressable: 'Pressable',
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Check: 'Check',
Copy: 'Copy',
Pencil: 'Pencil',
Play: 'Play',
Trash2: 'Trash2'
}))
vi.mock('expo-clipboard', () => clipboard)
vi.mock('expo-haptics', () => ({
...haptics,
performAndroidHapticsAsync: vi.fn(() => Promise.resolve()),
AndroidHaptics: { Reject: 'reject' },
NotificationFeedbackType: { Error: 'error', Success: 'success' }
}))
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
const COMMAND: TerminalQuickCommand = {
id: 'qc-1',
label: 'Run tests',
command: 'run the tests',
appendEnter: true
}
/**
* The seventh migrated write, and the one that does not answer a refusal with a toast.
*
* A row inside a scrolling list says so on its own control: the copy button's label becomes
* "Couldn't copy" and its icon turns red for the same 1500 ms the toast would have lasted. What it
* had no way to say was anything a thumb could feel, and the button sits under the thumb that just
* pressed it. The seam rejects on a refusal rather than resolving false, so these two cases are the
* difference between the row that shows a green check over nothing copied and the row that does not.
*/
describe('the quick-command row when the pasteboard refuses the text', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
clipboard.setStringAsync.mockReset()
haptics.notificationAsync.mockReset()
haptics.notificationAsync.mockImplementation(() => Promise.resolve())
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
/**
* The copy control, found by its label rather than its position among the row's four buttons.
*
* All four labels the button can carry, because the label is what the copy state changes: a
* finder keyed to one of them stops finding the button in the state it is meant to read.
*/
const COPY_LABELS = new Set([
`Copy ${COMMAND.label}`,
'Copied',
"Couldn't copy",
'Nothing to copy'
])
function copyButton() {
const button = renderer!.root
.findAll((node) => node.props.accessibilityRole === 'button')
.find((node) => COPY_LABELS.has(String(node.props.accessibilityLabel)))
if (button === undefined) {
throw new Error('the row has no copy button')
}
return button
}
async function mountAndCopy(): Promise<void> {
await act(async () => {
renderer = create(
createElement(QuickCommandRow, {
command: COMMAND,
first: true,
onLaunch: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
disabled: false
})
)
})
await act(async () => {
copyButton().props.onPress()
})
}
it('says it could not copy and buzzes the error', async () => {
clipboard.setStringAsync.mockResolvedValue(false)
await mountAndCopy()
expect(copyButton().props.accessibilityLabel).toBe("Couldn't copy")
expect(haptics.notificationAsync).toHaveBeenCalledWith('error')
})
it('shows the copied label and no error buzz when the write lands', async () => {
// The control: a failure assertion is only evidence if the success path reads differently.
clipboard.setStringAsync.mockResolvedValue(true)
await mountAndCopy()
expect(copyButton().props.accessibilityLabel).toBe('Copied')
expect(haptics.notificationAsync).not.toHaveBeenCalled()
})
})
+4
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { View, Text, Pressable, StyleSheet } from 'react-native'
import { useClipboardWriter } from '../platform/clipboard'
import { triggerError } from '../platform/haptics'
import { Check, Copy, Pencil, Play, Trash2 } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
@@ -74,6 +75,9 @@ export function QuickCommandRow({
}
setFeedback({ body, status: 'copied' })
} catch {
// The row says so on its own control rather than in a toast; the buzz is the part a thumb
// resting on the button it just pressed can notice without looking.
triggerError()
if (!mountedRef.current) {
return
}
@@ -27,6 +27,14 @@ vi.mock('lucide-react-native', () => ({
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
// The row buzzes when a copy is refused, and the real module reads `__DEV__` at import.
vi.mock('expo-haptics', () => ({
notificationAsync: vi.fn(),
performAndroidHapticsAsync: vi.fn(),
AndroidHaptics: { Reject: 'reject' },
NotificationFeedbackType: { Error: 'error', Success: 'success' }
}))
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
describe('QuickCommandsList search', () => {