From 02cbb52eeeb763232f5c756e826e92bb69fdf218 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 20 Sep 2026 08:33:33 -0400 Subject: [PATCH] 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 --- mobile/src/session/QuickCommandRow.test.ts | 122 +++++++++++++++++++ mobile/src/session/QuickCommandRow.tsx | 4 + mobile/src/session/QuickCommandsList.test.ts | 8 ++ 3 files changed, 134 insertions(+) create mode 100644 mobile/src/session/QuickCommandRow.test.ts diff --git a/mobile/src/session/QuickCommandRow.test.ts b/mobile/src/session/QuickCommandRow.test.ts new file mode 100644 index 00000000000..c7f7fa4f31c --- /dev/null +++ b/mobile/src/session/QuickCommandRow.test.ts @@ -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: (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 { + 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() + }) +}) diff --git a/mobile/src/session/QuickCommandRow.tsx b/mobile/src/session/QuickCommandRow.tsx index f251a00dd20..4bb9b8aec8f 100644 --- a/mobile/src/session/QuickCommandRow.tsx +++ b/mobile/src/session/QuickCommandRow.tsx @@ -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 } diff --git a/mobile/src/session/QuickCommandsList.test.ts b/mobile/src/session/QuickCommandsList.test.ts index 37e41990a08..aae0bf7bbe3 100644 --- a/mobile/src/session/QuickCommandsList.test.ts +++ b/mobile/src/session/QuickCommandsList.test.ts @@ -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', () => {