Skip to content

Commit be16925

Browse files
authored
feat(mobile): copy the stored custom instructions from the retirement notice (#9477)
* feat(mobile): copy the stored custom instructions from the retirement notice The deprecation notice on the Custom instructions screen told readers to move their guidelines into a REVIEW.md file without giving them a way to take the text out: the field shows ten lines of a value that can be far longer and scrolls the rest, so the whole value had to be selected by hand inside that window. The notice now carries a Copy action that writes the stored value through the app's shared copy path, so the confirmation toast and the failure path match the other copy surfaces. With nothing stored the action is not rendered. The copy reuses the existing `common.copy`, `common.copiedToClipboard` and `common.couldNotCopyToClipboard` keys, which every catalog already defines. * fix(mobile): give the retirement notice its full width back Beside the notice text the copy action narrowed the message into a tall four-line column on a compact screen, and in RTL it left a one-word last line. The action now takes its own row under the text, packed to the end of the row so it keeps the card's content edge in either direction, and the message keeps the whole banner width.
1 parent 2b26d1c commit be16925

3 files changed

Lines changed: 134 additions & 9 deletions

File tree

‎apps/mobile/src/app/(app)/code-reviewer/[scope]/[platform]/(edit)/instructions.mounted.test.tsx‎

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ const queryError = vi.hoisted(() => ({
3131
props: [] as { variant?: string; onRetry?: () => void; isRetrying?: boolean }[],
3232
}));
3333
const save = vi.hoisted(() => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }));
34+
// The app's shared clipboard helper; the notice's action must go through it so
35+
// the copy gets the same confirmation toast and failure path as every other
36+
// copy in the app.
37+
const clipboardCopy = vi.hoisted(() => ({ performCopy: vi.fn() }));
3438
const router = vi.hoisted(() => ({ back: vi.fn(), push: vi.fn() }));
3539
const navigation = vi.hoisted(() => ({ dispatch: vi.fn(), goBack: vi.fn() }));
3640
const preventRemove = vi.hoisted(() => ({
@@ -143,9 +147,12 @@ vi.mock('@/components/destructive-confirm-dialog', () => ({
143147
}));
144148
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
145149
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
146-
vi.mock('@/components/ui/icons', () => ({ AlertTriangle: 'AlertTriangle' }));
150+
vi.mock('@/components/ui/icons', () => ({ AlertTriangle: 'AlertTriangle', Copy: 'Copy' }));
151+
vi.mock('@/components/agents/use-message-copy', () => ({
152+
performCopy: clipboardCopy.performCopy,
153+
}));
147154
vi.mock('@/lib/hooks/use-theme-colors', () => ({
148-
useThemeColors: () => ({ warn: '#f2b05f' }),
155+
useThemeColors: () => ({ foreground: '#f5f5f5', warn: '#f2b05f' }),
149156
}));
150157
vi.mock('@/components/invalid-route-state', () => ({ InvalidRouteState: 'InvalidRouteState' }));
151158
vi.mock('@/components/screen-header', () => ({
@@ -210,6 +217,26 @@ function triggerPreventRemove() {
210217

211218
const STORED = 'first line\nsecond line\nthird line\nfourth line\nfifth line\nsixth line';
212219

220+
// The shape the notice's copy action exists for: more lines than the field's
221+
// ten-line cap can show at once, with accents, curly quotes and an emoji, so a
222+
// copy that dropped, re-encoded or truncated anything would show.
223+
const LONG_STORED = [
224+
'Regras gerais:',
225+
'1. Prefira commits pequenos, com mensagem no imperativo.',
226+
'2. “Nunca” reescreva histórico publicado: acrescente um commit.',
227+
'3. Não use `any`; prefira `unknown` e estreite o tipo na origem.',
228+
'4. Comentários explicam o porquê, não o quê.',
229+
'5. Testes cobrem comportamento, não a implementação.',
230+
'6. Sempre rode o lint e o typecheck antes de abrir o MR.',
231+
'7. Não adicione dependências sem justificar no MR.',
232+
'8. Migrações são aditivas; a remoção vem em um MR próprio.',
233+
'9. Erros de rede têm retry limitado e mensagem clara.',
234+
'10. Strings novas entram em todas as línguas.',
235+
'11. Componentes novos reutilizam os compartilhados.',
236+
'12. Emoji nos testes: 🧪 — mantenha o texto exato.',
237+
'Fim.',
238+
].join('\n');
239+
213240
// The TextInput mock records on every render; the last entry is the current
214241
// one. Six tests need the same lockstep lookup, so it lives here.
215242
function latestInput() {
@@ -236,6 +263,32 @@ function pressSaveButton(renderer: TestRenderer.ReactTestRenderer) {
236263
(onPress as () => void)();
237264
}
238265

266+
// The notice's copy action. The editor renders two buttons and Save is the
267+
// last, so the label it renders is what tells them apart.
268+
function findCopyButton(renderer: TestRenderer.ReactTestRenderer) {
269+
return renderer.root
270+
.findAll(node => String(node.type) === 'Button')
271+
.find(button =>
272+
button
273+
.findAll(node => String(node.type) === 'Text')
274+
.some(text => text.props.children === i18n.t('common.copy'))
275+
);
276+
}
277+
278+
function pressCopyButton(renderer: TestRenderer.ReactTestRenderer) {
279+
const button = findCopyButton(renderer);
280+
if (!button) {
281+
throw new Error('copy action not found');
282+
}
283+
const onPress = (button.props as Record<string, unknown>).onPress;
284+
if (typeof onPress !== 'function') {
285+
throw new TypeError('copy action onPress is not a function');
286+
}
287+
act(() => {
288+
(onPress as () => void)();
289+
});
290+
}
291+
239292
// Runs `trigger` inside act and lets the screen's async save (mutation, then
240293
// navigation, or a pending confirmation) settle before the assertions.
241294
async function flush(trigger: () => void) {
@@ -262,6 +315,7 @@ beforeEach(() => {
262315
save.mutate.mockReset();
263316
save.mutateAsync.mockReset();
264317
save.mutateAsync.mockResolvedValue({});
318+
clipboardCopy.performCopy.mockReset();
265319
router.back.mockReset();
266320
router.push.mockReset();
267321
navigation.dispatch.mockReset();
@@ -664,6 +718,44 @@ describe('InstructionsRoute editor', () => {
664718
});
665719
});
666720

721+
// The notice tells the reader to move the instructions into REVIEW.md, and the
722+
// field below it shows ten lines of a value that can be far longer. The action
723+
// beside the notice is the way out: it hands over the whole stored value in one
724+
// press through the app's shared clipboard helper, which owns the confirmation
725+
// toast and the failure path.
726+
describe('InstructionsRoute retirement notice copy action', () => {
727+
it('copies the whole stored value, not the window the field shows', () => {
728+
config.data = { customInstructions: LONG_STORED };
729+
const renderer = mount();
730+
731+
pressCopyButton(renderer);
732+
733+
expect(clipboardCopy.performCopy).toHaveBeenCalledTimes(1);
734+
expect(clipboardCopy.performCopy).toHaveBeenCalledWith(LONG_STORED);
735+
});
736+
737+
it('copies the stored value while an unsaved edit sits in the field', () => {
738+
config.data = { customInstructions: STORED };
739+
const renderer = mount();
740+
act(() => {
741+
latestInput().onChangeText('an unsaved rewrite');
742+
});
743+
744+
pressCopyButton(renderer);
745+
746+
// The notice retires the stored instructions; the edit is not saved yet, so
747+
// it is not what the reader is being told to carry over.
748+
expect(clipboardCopy.performCopy).toHaveBeenCalledWith(STORED);
749+
});
750+
751+
it('offers no copy action when nothing is stored', () => {
752+
config.data = { customInstructions: '' };
753+
const renderer = mount();
754+
755+
expect(findCopyButton(renderer)).toBeUndefined();
756+
});
757+
});
758+
667759
// The iOS accessory bar is the only keyboard-adjacent way out of this
668760
// multiline field, and iOS binds it to the first text input in the window whose
669761
// `inputAccessoryViewID` matches the bar's `nativeID`, once, when the bar enters

‎apps/mobile/src/app/(app)/code-reviewer/[scope]/[platform]/(edit)/instructions.tsx‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ import Animated, { FadeOut, LinearTransition } from 'react-native-reanimated';
1313

1414
import { InvalidRouteState } from '@/components/invalid-route-state';
1515
import { QueryError } from '@/components/query-error';
16+
import { performCopy } from '@/components/agents/use-message-copy';
1617
import { ReviewerHeader } from '@/components/code-reviewer/reviewer-header';
1718
import { Button } from '@/components/ui/button';
1819
import { useConfirmDialog } from '@/components/ui/dialog';
19-
import { AlertTriangle } from '@/components/ui/icons';
20+
import { AlertTriangle, Copy } from '@/components/ui/icons';
2021
import { Skeleton } from '@/components/ui/skeleton';
2122
import { Text } from '@/components/ui/text';
2223
import { TabScreenScrollView } from '@/components/tab-screen';
@@ -63,6 +64,9 @@ function InstructionsEditor({
6364
}>) {
6465
const { t } = useTranslation();
6566
const colors = useThemeColors();
67+
// The value the notice hands over: the stored instructions, trimmed the way
68+
// Save persists them, so the copied text is the value the backend holds.
69+
const storedValue = initial.trim();
6670
// Stable for this mount and distinct from every other mounted editor, so the
6771
// bar below can only bind to this editor's field.
6872
const accessoryId = `${INSTRUCTIONS_DONE_ACCESSORY_ID}${useId()}`;
@@ -109,12 +113,40 @@ function InstructionsEditor({
109113
<View className="gap-4">
110114
{/* The deprecation note asks the reader to move the guidelines elsewhere;
111115
as body-coloured text it read as a field description. A tinted notice
112-
with its own mark separates it from the field below. */}
113-
<View className="flex-row items-start gap-2 rounded-xl border border-warn-tile-border bg-warn-tile-bg p-3">
114-
<AlertTriangle size={16} color={colors.warn} />
115-
<Text className="min-w-0 flex-1 text-sm text-warn">
116-
{t('codeReviewer.instructions.deprecation')}
117-
</Text>
116+
with its own mark separates it from the field below. The action under
117+
it hands over the whole stored value: the field shows at most ten
118+
lines and scrolls the rest, so selecting the text by hand meant
119+
dragging through a window that never shows it all. */}
120+
<View className="gap-2 rounded-xl border border-warn-tile-border bg-warn-tile-bg p-3">
121+
<View className="flex-row items-start gap-2">
122+
<AlertTriangle size={16} color={colors.warn} />
123+
<Text className="min-w-0 flex-1 text-sm text-warn">
124+
{t('codeReviewer.instructions.deprecation')}
125+
</Text>
126+
</View>
127+
{storedValue === '' ? null : (
128+
// What is copied is the value the notice retires — what the backend
129+
// holds and a save writes — not the draft under it. With nothing
130+
// stored there is no action rather than one that copies nothing.
131+
// The action takes its own row under the notice: beside the text it
132+
// narrowed the message to a four-line column on a compact phone and
133+
// left a one-word last line in RTL, and it grew the banner taller
134+
// than the message needs. `justify-end` packs the row to the end in
135+
// either direction, so the action keeps the margin the text above it
136+
// shares instead of a physical edge.
137+
<View className="flex-row justify-end">
138+
<Button
139+
variant="outline"
140+
size="sm"
141+
onPress={() => {
142+
void performCopy(storedValue);
143+
}}
144+
>
145+
<Copy size={14} color={colors.foreground} />
146+
<Text>{t('common.copy')}</Text>
147+
</Button>
148+
</View>
149+
)}
118150
</View>
119151
{/* A multiline field takes the platform's generic text-view name on iOS,
120152
so the accessible name is the localized heading this screen renders.

‎apps/mobile/src/components/ui/icons.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export { default as Clock3 } from 'lucide-react-native/icons/clock-3';
4040
export { default as Cloud } from 'lucide-react-native/icons/cloud';
4141
export { default as Code } from 'lucide-react-native/icons/code';
4242
export { default as Columns2 } from 'lucide-react-native/icons/columns-2';
43+
export { default as Copy } from 'lucide-react-native/icons/copy';
4344
export { default as CornerDownLeft } from 'lucide-react-native/icons/corner-down-left';
4445
export { default as Cpu } from 'lucide-react-native/icons/cpu';
4546
export { default as CreditCard } from 'lucide-react-native/icons/credit-card';

0 commit comments

Comments
 (0)