@tapthrough/expo 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -2
  3. package/app.plugin.d.ts +37 -0
  4. package/app.plugin.js +79 -0
  5. package/dist/Annotator.d.ts +14 -0
  6. package/dist/Annotator.d.ts.map +1 -0
  7. package/dist/Annotator.js +160 -0
  8. package/dist/Annotator.js.map +1 -0
  9. package/dist/Bubble.d.ts +27 -0
  10. package/dist/Bubble.d.ts.map +1 -0
  11. package/dist/Bubble.js +234 -0
  12. package/dist/Bubble.js.map +1 -0
  13. package/dist/Feedback.d.ts +14 -0
  14. package/dist/Feedback.d.ts.map +1 -0
  15. package/dist/Feedback.js +977 -0
  16. package/dist/Feedback.js.map +1 -0
  17. package/dist/Overlay.d.ts +13 -0
  18. package/dist/Overlay.d.ts.map +1 -0
  19. package/dist/Overlay.js +44 -0
  20. package/dist/Overlay.js.map +1 -0
  21. package/dist/Popover.d.ts +98 -0
  22. package/dist/Popover.d.ts.map +1 -0
  23. package/dist/Popover.js +531 -0
  24. package/dist/Popover.js.map +1 -0
  25. package/dist/Tapthrough.d.ts +27 -0
  26. package/dist/Tapthrough.d.ts.map +1 -0
  27. package/dist/Tapthrough.js +111 -0
  28. package/dist/Tapthrough.js.map +1 -0
  29. package/dist/core/annotations.d.ts +63 -0
  30. package/dist/core/annotations.d.ts.map +1 -0
  31. package/dist/core/annotations.js +76 -0
  32. package/dist/core/annotations.js.map +1 -0
  33. package/dist/core/bubble.d.ts +21 -0
  34. package/dist/core/bubble.d.ts.map +1 -0
  35. package/dist/core/bubble.js +25 -0
  36. package/dist/core/bubble.js.map +1 -0
  37. package/dist/core/controller.d.ts +46 -0
  38. package/dist/core/controller.d.ts.map +1 -0
  39. package/dist/core/controller.js +103 -0
  40. package/dist/core/controller.js.map +1 -0
  41. package/dist/core/diagnostics.d.ts +47 -0
  42. package/dist/core/diagnostics.d.ts.map +1 -0
  43. package/dist/core/diagnostics.js +101 -0
  44. package/dist/core/diagnostics.js.map +1 -0
  45. package/dist/core/feedback.d.ts +302 -0
  46. package/dist/core/feedback.d.ts.map +1 -0
  47. package/dist/core/feedback.js +345 -0
  48. package/dist/core/feedback.js.map +1 -0
  49. package/dist/core/flags.d.ts +13 -0
  50. package/dist/core/flags.d.ts.map +1 -0
  51. package/dist/core/flags.js +25 -0
  52. package/dist/core/flags.js.map +1 -0
  53. package/dist/core/ideas.d.ts +166 -0
  54. package/dist/core/ideas.d.ts.map +1 -0
  55. package/dist/core/ideas.js +159 -0
  56. package/dist/core/ideas.js.map +1 -0
  57. package/dist/core/launch.d.ts +43 -0
  58. package/dist/core/launch.d.ts.map +1 -0
  59. package/dist/core/launch.js +81 -0
  60. package/dist/core/launch.js.map +1 -0
  61. package/dist/core/reset.d.ts +17 -0
  62. package/dist/core/reset.d.ts.map +1 -0
  63. package/dist/core/reset.js +19 -0
  64. package/dist/core/reset.js.map +1 -0
  65. package/dist/core/review.d.ts +49 -0
  66. package/dist/core/review.d.ts.map +1 -0
  67. package/dist/core/review.js +92 -0
  68. package/dist/core/review.js.map +1 -0
  69. package/dist/core/url.d.ts +9 -0
  70. package/dist/core/url.d.ts.map +1 -0
  71. package/dist/core/url.js +23 -0
  72. package/dist/core/url.js.map +1 -0
  73. package/dist/hooks.d.ts +6 -0
  74. package/dist/hooks.d.ts.map +1 -0
  75. package/dist/hooks.js +13 -0
  76. package/dist/hooks.js.map +1 -0
  77. package/dist/index.d.ts +9 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +7 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/native-intent.d.ts +18 -0
  82. package/dist/native-intent.d.ts.map +1 -0
  83. package/dist/native-intent.js +32 -0
  84. package/dist/native-intent.js.map +1 -0
  85. package/dist/runtime.d.ts +24 -0
  86. package/dist/runtime.d.ts.map +1 -0
  87. package/dist/runtime.js +17 -0
  88. package/dist/runtime.js.map +1 -0
  89. package/dist/screenshot.d.ts +7 -0
  90. package/dist/screenshot.d.ts.map +1 -0
  91. package/dist/screenshot.js +48 -0
  92. package/dist/screenshot.js.map +1 -0
  93. package/dist/supabase.d.ts +20 -0
  94. package/dist/supabase.d.ts.map +1 -0
  95. package/dist/supabase.js +18 -0
  96. package/dist/supabase.js.map +1 -0
  97. package/dist/ui/native.d.ts +34 -0
  98. package/dist/ui/native.d.ts.map +1 -0
  99. package/dist/ui/native.js +76 -0
  100. package/dist/ui/native.js.map +1 -0
  101. package/dist/ui/parts.d.ts +62 -0
  102. package/dist/ui/parts.d.ts.map +1 -0
  103. package/dist/ui/parts.js +110 -0
  104. package/dist/ui/parts.js.map +1 -0
  105. package/dist/ui/theme.d.ts +90 -0
  106. package/dist/ui/theme.d.ts.map +1 -0
  107. package/dist/ui/theme.js +61 -0
  108. package/dist/ui/theme.js.map +1 -0
  109. package/package.json +82 -3
  110. package/src/Annotator.tsx +279 -0
  111. package/src/Bubble.tsx +331 -0
  112. package/src/Feedback.tsx +1739 -0
  113. package/src/Overlay.tsx +61 -0
  114. package/src/Popover.tsx +1393 -0
  115. package/src/Tapthrough.tsx +171 -0
  116. package/src/core/annotations.ts +116 -0
  117. package/src/core/bubble.ts +45 -0
  118. package/src/core/controller.ts +119 -0
  119. package/src/core/diagnostics.ts +128 -0
  120. package/src/core/feedback.ts +589 -0
  121. package/src/core/flags.ts +31 -0
  122. package/src/core/ideas.ts +337 -0
  123. package/src/core/launch.ts +119 -0
  124. package/src/core/reset.ts +28 -0
  125. package/src/core/review.ts +148 -0
  126. package/src/core/url.ts +21 -0
  127. package/src/hooks.ts +15 -0
  128. package/src/index.ts +8 -0
  129. package/src/native-intent.ts +36 -0
  130. package/src/runtime.ts +30 -0
  131. package/src/screenshot.ts +64 -0
  132. package/src/supabase.ts +27 -0
  133. package/src/ui/native.ts +116 -0
  134. package/src/ui/parts.tsx +249 -0
  135. package/src/ui/theme.ts +75 -0
@@ -0,0 +1,977 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import Constants from 'expo-constants';
3
+ import { usePathname } from 'expo-router';
4
+ import { useEffect, useMemo, useRef, useState } from 'react';
5
+ import { ActivityIndicator, Animated, AppState, Image, Keyboard, KeyboardAvoidingView, Linking, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useWindowDimensions, View, } from 'react-native';
6
+ import { Annotator } from './Annotator.js';
7
+ import { Bubble, COLORS, screenBounds } from './Bubble.js';
8
+ import { defaultPosition } from './core/bubble.js';
9
+ import { createDiagnostics, installDiagnostics } from './core/diagnostics.js';
10
+ import { failedStepBody, fetchChecklist, fileIssue, previewContextFromManifest, requestFix, requestLaunch, reviewBadge, sendCause, sendFeedback, sendRecheck, sendStepResult, } from './core/feedback.js';
11
+ import { answerIdea, approveMessage, fetchIdeas, fetchOpenPrs, ideaAction, ideasNeedingYou, isMoving, sendStaffReport, submitIdea, } from './core/ideas.js';
12
+ import { nextOpenStep, popoverPlacement, resultsFrom, stepId } from './core/review.js';
13
+ import { useLaunchState } from './hooks.js';
14
+ import { Overlay, useBackPress } from './Overlay.js';
15
+ import { ReviewerName, ReviewPopover, } from './Popover.js';
16
+ import { controller } from './runtime.js';
17
+ import { captureScreenshot } from './screenshot.js';
18
+ import { haptics, useReduceMotion } from './ui/native.js';
19
+ import { Capsule, CircleButton, Icon, Surface } from './ui/parts.js';
20
+ import { SPRING, text, useColors } from './ui/theme.js';
21
+ /** Remembered for the session, so reviewers type their name once. */
22
+ let rememberedName = '';
23
+ /** Where idea updates go, if the person gave an email; also kept for the session. */
24
+ let rememberedEmail = '';
25
+ const diagnostics = createDiagnostics();
26
+ let diagnosticsInstalled = false;
27
+ function deviceInfo() {
28
+ const constants = Platform.constants;
29
+ if (Platform.OS === 'ios') {
30
+ return {
31
+ os: 'iOS',
32
+ osVersion: String(Platform.Version),
33
+ model: constants.interfaceIdiom === 'pad' ? 'iPad' : 'iPhone',
34
+ };
35
+ }
36
+ const model = [constants.Manufacturer, constants.Model].filter(Boolean).join(' ');
37
+ return {
38
+ os: 'Android',
39
+ osVersion: String(constants.Release ?? Platform.Version),
40
+ ...(model ? { model } : {}),
41
+ };
42
+ }
43
+ /** e.g. "iPhone · iOS 26.1", for the composer's list of what's attached. */
44
+ function deviceLabel() {
45
+ const { os, osVersion, model } = deviceInfo();
46
+ return `${model ?? os} · ${os} ${osVersion}`;
47
+ }
48
+ /** The app's version and build, from its config, for reports from staff builds. */
49
+ function appVersion() {
50
+ const config = Constants.expoConfig;
51
+ const build = Platform.OS === 'ios' ? config?.ios?.buildNumber : config?.android?.versionCode;
52
+ return {
53
+ ...(config?.version ? { version: config.version } : {}),
54
+ ...(build !== undefined ? { build: String(build) } : {}),
55
+ };
56
+ }
57
+ /** e.g. "Version 2.4.0 (311)", for staff builds' panel and composer. */
58
+ function appLabel() {
59
+ const { version, build } = appVersion();
60
+ if (!version)
61
+ return 'This build';
62
+ return build ? `Version ${version} (${build})` : `Version ${version}`;
63
+ }
64
+ /** What a staff build's panel shows instead of a PR's checklist: nothing to check. */
65
+ const STAFF_CHECKLIST = {
66
+ pr: 0,
67
+ sha: '',
68
+ open: true,
69
+ scenarios: [],
70
+ recheck: [],
71
+ library: [],
72
+ newerBuild: null,
73
+ reset: null,
74
+ };
75
+ function nextFrame() {
76
+ return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
77
+ }
78
+ /**
79
+ * Puts the app's keyboard away. A sheet that opens while it's up can't tell
80
+ * it's there, so the bottom of the sheet would sit under it. Resolves once it's
81
+ * gone, so a screenshot taken next doesn't catch it halfway down.
82
+ */
83
+ function dismissKeyboard() {
84
+ if (!Keyboard.isVisible())
85
+ return Promise.resolve();
86
+ return new Promise((resolve) => {
87
+ const done = () => {
88
+ subscription.remove();
89
+ clearTimeout(fallback);
90
+ resolve();
91
+ };
92
+ const subscription = Keyboard.addListener('keyboardDidHide', done);
93
+ // In case the event never comes, e.g. a hardware keyboard.
94
+ const fallback = setTimeout(done, 500);
95
+ Keyboard.dismiss();
96
+ });
97
+ }
98
+ /** How often a PR preview checks for new results and newer builds while in the foreground. */
99
+ const REFRESH_MS = 30_000;
100
+ /** How often an open idea that's moving checks for its next step. */
101
+ const IDEA_REFRESH_MS = 8_000;
102
+ /** Update groups the reviewer has already been told about, so the toast shows once. */
103
+ const announcedBuilds = new Set();
104
+ /**
105
+ * A small draggable Review button over a PR preview. Tapping it grows a
106
+ * popover listing what the PR needs tested; picking a scenario can set the app
107
+ * up for it and then walks through its steps. Without a plan it goes straight
108
+ * to reporting an issue with a screenshot of the screen behind it. It keeps
109
+ * the checklist fresh in the background, so it can say when a newer build is out.
110
+ */
111
+ export function ReportButton({ apiUrl, canSetUp, appKey, }) {
112
+ const preview = useMemo(() => previewContextFromManifest(Constants.manifest2), []);
113
+ // A PR build proves itself with its update; any other build needs the app key.
114
+ const caller = useMemo(() => (preview ? { kind: 'build', preview } : appKey ? { kind: 'key', appKey } : null), [preview, appKey]);
115
+ const staffMode = caller?.kind === 'key';
116
+ const pathname = usePathname();
117
+ const { width, height } = useWindowDimensions();
118
+ const bounds = screenBounds(width, height);
119
+ const [phase, setPhase] = useState('idle');
120
+ const [checklist, setChecklist] = useState(staffMode ? STAFF_CHECKLIST : null);
121
+ const [nav, setNav] = useState({ view: 'list' });
122
+ const [direction, setDirection] = useState('forward');
123
+ const [full, setFull] = useState(false);
124
+ const [overrides, setOverrides] = useState({});
125
+ const [rechecked, setRechecked] = useState({});
126
+ const [setup, setSetup] = useState(null);
127
+ const [anchor, setAnchor] = useState(null);
128
+ const [error, setError] = useState(null);
129
+ const [name, setName] = useState(rememberedName);
130
+ const [failing, setFailing] = useState(null);
131
+ const [screenshot, setScreenshot] = useState(null);
132
+ const [toast, setToast] = useState(null);
133
+ const [ideas, setIdeas] = useState([]);
134
+ const [ideaBusy, setIdeaBusy] = useState(null);
135
+ const [pulls, setPulls] = useState(null);
136
+ const [composerKind, setComposerKind] = useState('issue');
137
+ const launch = useLaunchState();
138
+ const platform = Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : null;
139
+ const results = useMemo(() => ({ ...resultsFrom(checklist), ...overrides }), [checklist, overrides]);
140
+ // PR previews and staff builds record diagnostics; store builds are never hooked.
141
+ useEffect(() => {
142
+ if (!caller || diagnosticsInstalled)
143
+ return;
144
+ diagnosticsInstalled = true;
145
+ installDiagnostics(diagnostics, {
146
+ console,
147
+ errorUtils: typeof ErrorUtils === 'undefined' ? undefined : ErrorUtils,
148
+ fetchHost: globalThis,
149
+ }, apiUrl);
150
+ }, [caller, apiUrl]);
151
+ useEffect(() => {
152
+ if (caller)
153
+ diagnostics.recordScreen(pathname);
154
+ }, [caller, pathname]);
155
+ async function refresh() {
156
+ if (!caller || !platform)
157
+ return null;
158
+ const [list, recent] = await Promise.all([
159
+ preview ? fetchChecklist(preview, platform, { apiUrl }) : Promise.resolve(STAFF_CHECKLIST),
160
+ fetchIdeas(caller, platform, { apiUrl }),
161
+ ]);
162
+ if (list)
163
+ setChecklist(list);
164
+ setIdeas(recent);
165
+ const newer = list?.newerBuild;
166
+ if (newer && !announcedBuilds.has(newer.updateGroupId)) {
167
+ announcedBuilds.add(newer.updateGroupId);
168
+ setToast(newer);
169
+ }
170
+ return list;
171
+ }
172
+ // Fetch up front so the popover opens instantly, then poll while the app is in front.
173
+ // biome-ignore lint/correctness/useExhaustiveDependencies: refresh reads current props
174
+ useEffect(() => {
175
+ if (!caller || !platform)
176
+ return;
177
+ let timer;
178
+ const start = () => {
179
+ clearInterval(timer);
180
+ void refresh();
181
+ timer = setInterval(() => void refresh(), REFRESH_MS);
182
+ };
183
+ const stop = () => clearInterval(timer);
184
+ if (AppState.currentState === 'active')
185
+ start();
186
+ const subscription = AppState.addEventListener('change', (next) => next === 'active' ? start() : stop());
187
+ return () => {
188
+ stop();
189
+ subscription.remove();
190
+ };
191
+ }, [caller, platform, apiUrl]);
192
+ // Watching an idea that's being written up or built: check every few seconds, not every 30.
193
+ const watched = nav.view === 'idea' ? ideas.find((i) => i.id === nav.id) : undefined;
194
+ const watching = phase === 'open' && watched !== undefined && isMoving(watched);
195
+ useEffect(() => {
196
+ if (!watching || !caller || !platform)
197
+ return;
198
+ const timer = setInterval(async () => {
199
+ setIdeas(await fetchIdeas(caller, platform, { apiUrl }));
200
+ }, IDEA_REFRESH_MS);
201
+ return () => clearInterval(timer);
202
+ }, [watching, caller, platform, apiUrl]);
203
+ useEffect(() => {
204
+ if (!toast)
205
+ return;
206
+ const timer = setTimeout(() => setToast(null), 6000);
207
+ return () => clearTimeout(timer);
208
+ }, [toast]);
209
+ // A setup this popover started: follow the launch through its stages.
210
+ useEffect(() => {
211
+ if (!setup || setup.error || setup.progress === 'done')
212
+ return;
213
+ if (launch.status === 'idle' || launch.source !== 'app')
214
+ return;
215
+ if (launch.status === 'launching') {
216
+ if (launch.stage && launch.stage !== setup.progress) {
217
+ setSetup({ ...setup, progress: launch.stage });
218
+ }
219
+ return;
220
+ }
221
+ controller.dismiss();
222
+ if (launch.status === 'error') {
223
+ haptics.warning();
224
+ setSetup({ ...setup, error: launch.error.message });
225
+ return;
226
+ }
227
+ haptics.success();
228
+ setSetup({ ...setup, progress: 'done' });
229
+ }, [launch, setup]);
230
+ // Once every line is ticked, let it show for a moment, then start the steps.
231
+ // biome-ignore lint/correctness/useExhaustiveDependencies: begin reads the current checklist
232
+ useEffect(() => {
233
+ if (setup?.progress !== 'done' || setup.error)
234
+ return;
235
+ const key = setup.key;
236
+ const timer = setTimeout(() => {
237
+ setSetup(null);
238
+ begin(key);
239
+ void refresh();
240
+ }, 700);
241
+ return () => clearTimeout(timer);
242
+ }, [setup]);
243
+ if (!caller || !platform)
244
+ return null;
245
+ function go(next, dir = 'forward') {
246
+ setDirection(dir);
247
+ setNav(next);
248
+ setError(null);
249
+ }
250
+ function rememberName() {
251
+ rememberedName = name.trim();
252
+ }
253
+ /** Starts a scenario's steps where they're left off, or explores another state. */
254
+ function begin(key) {
255
+ const scenario = checklist?.scenarios.find((s) => s.key === key);
256
+ if (!scenario?.steps.length) {
257
+ go({ view: 'explore', key });
258
+ return;
259
+ }
260
+ const index = nextOpenStep(scenario, results);
261
+ go(index >= 0 ? { view: 'step', key, index } : { view: 'done', key });
262
+ }
263
+ async function setUp(key) {
264
+ if (!preview || !platform)
265
+ return;
266
+ haptics.selection();
267
+ controller.dismiss();
268
+ go({ view: 'setup', key });
269
+ setSetup({ key, progress: 'requesting' });
270
+ const result = await requestLaunch({ preview, platform, scenarioKey: key }, { apiUrl });
271
+ if (!result.ok) {
272
+ haptics.warning();
273
+ setSetup({ key, progress: 'requesting', error: result.message });
274
+ return;
275
+ }
276
+ if (!controller.offer(result.launchUrl, 'app')) {
277
+ setSetup({
278
+ key,
279
+ progress: 'requesting',
280
+ error: 'That setup link was already used. Try again.',
281
+ });
282
+ }
283
+ }
284
+ async function pass(key, index) {
285
+ const scenario = checklist?.scenarios.find((s) => s.key === key);
286
+ const step = scenario?.steps[index];
287
+ if (!preview || !platform || !scenario || !step)
288
+ return;
289
+ rememberName();
290
+ haptics.selection();
291
+ const id = stepId(key, step.index);
292
+ const previous = results[id];
293
+ const after = { ...results, [id]: 'pass' };
294
+ setOverrides((current) => ({ ...current, [id]: 'pass' }));
295
+ // Let the green show, then slide in the next step.
296
+ setTimeout(() => {
297
+ const next = nextOpenStep(scenario, after, index + 1);
298
+ go(next >= 0 ? { view: 'step', key, index: next } : { view: 'done', key });
299
+ if (next < 0)
300
+ haptics.success();
301
+ }, 280);
302
+ const sent = await sendStepResult({
303
+ preview,
304
+ platform,
305
+ scenarioKey: key,
306
+ stepIndex: step.index,
307
+ result: 'pass',
308
+ ...(rememberedName ? { reporterName: rememberedName } : {}),
309
+ }, { apiUrl });
310
+ if (!sent.ok) {
311
+ setOverrides((current) => ({ ...current, [id]: previous }));
312
+ setError(sent.message);
313
+ }
314
+ }
315
+ function fail(key, index) {
316
+ const step = checklist?.scenarios.find((s) => s.key === key)?.steps[index];
317
+ if (!step)
318
+ return;
319
+ rememberName();
320
+ haptics.warning();
321
+ void startReport({ scenarioKey: key, step, position: index });
322
+ }
323
+ async function recheck(item, result) {
324
+ if (!preview || !platform)
325
+ return;
326
+ rememberName();
327
+ haptics.selection();
328
+ setRechecked((current) => ({ ...current, [item.id]: result }));
329
+ setTimeout(() => go({ view: 'list' }, 'back'), 350);
330
+ const sent = await sendRecheck({
331
+ preview,
332
+ platform,
333
+ findingId: item.id,
334
+ result,
335
+ ...(rememberedName ? { reporterName: rememberedName } : {}),
336
+ }, { apiUrl });
337
+ if (!sent.ok) {
338
+ setRechecked(({ [item.id]: _, ...rest }) => rest);
339
+ setError(sent.message);
340
+ }
341
+ }
342
+ async function toggle() {
343
+ if (phase === 'open') {
344
+ close();
345
+ return;
346
+ }
347
+ setToast(null);
348
+ const keyboardGone = dismissKeyboard();
349
+ // A setup that finished or failed while closed shouldn't leave a spinner behind.
350
+ if (nav.view === 'setup' && !setup)
351
+ setNav({ view: 'list' });
352
+ // With a checklist the popover always opens, so ideas and other PRs are a tap away.
353
+ if (checklist) {
354
+ setPhase('open');
355
+ void refresh();
356
+ return;
357
+ }
358
+ setPhase('loading');
359
+ const list = await refresh();
360
+ if (list) {
361
+ setPhase('open');
362
+ }
363
+ else {
364
+ await keyboardGone;
365
+ await startReport(null);
366
+ }
367
+ }
368
+ function close() {
369
+ setPhase('idle');
370
+ setFull(false);
371
+ setError(null);
372
+ }
373
+ /** Hides everything, screenshots the app, then opens the composer. */
374
+ async function startReport(step, kind = 'issue') {
375
+ setFailing(step);
376
+ setComposerKind(step ? 'issue' : kind);
377
+ setToast(null);
378
+ setPhase('capturing');
379
+ try {
380
+ await nextFrame();
381
+ setScreenshot(await captureScreenshot());
382
+ }
383
+ finally {
384
+ // Whatever happens to the screenshot, the reviewer gets the composer.
385
+ setPhase('composing');
386
+ }
387
+ }
388
+ /** Back from the composer without sending: to the popover if that's where it started. */
389
+ function cancelReport() {
390
+ setScreenshot(null);
391
+ setFailing(null);
392
+ setPhase(checklist ? 'open' : 'idle');
393
+ }
394
+ /** After an idea is sent: straight to it in the popover, where its progress shows. */
395
+ function viewIdea(idea) {
396
+ setIdeas((current) => [idea, ...current.filter((i) => i.id !== idea.id)]);
397
+ setScreenshot(null);
398
+ setFailing(null);
399
+ go({ view: 'idea', id: idea.id });
400
+ setPhase(checklist ? 'open' : 'idle');
401
+ }
402
+ function replaceIdea(idea) {
403
+ setIdeas((current) => current.map((i) => (i.id === idea.id ? idea : i)));
404
+ }
405
+ async function answer(id, answers) {
406
+ if (!caller || !platform)
407
+ return;
408
+ rememberName();
409
+ setIdeaBusy(id);
410
+ const result = await answerIdea({ caller, platform, id, answers, ...(rememberedName ? { by: rememberedName } : {}) }, { apiUrl });
411
+ setIdeaBusy(null);
412
+ if (!result.ok) {
413
+ haptics.warning();
414
+ setError(result.message);
415
+ return;
416
+ }
417
+ haptics.success();
418
+ replaceIdea(result.idea);
419
+ }
420
+ async function act(id, action) {
421
+ if (!caller || !platform)
422
+ return;
423
+ rememberName();
424
+ haptics.selection();
425
+ setIdeaBusy(id);
426
+ const result = await ideaAction({ caller, platform, id, action, ...(rememberedName ? { by: rememberedName } : {}) }, { apiUrl });
427
+ setIdeaBusy(null);
428
+ if (!result.ok) {
429
+ haptics.warning();
430
+ setError(result.message);
431
+ return;
432
+ }
433
+ haptics.success();
434
+ replaceIdea(action === 'approve' ? { ...result.idea, note: approveMessage(result.approve) } : result.idea);
435
+ }
436
+ async function showPulls() {
437
+ if (!preview || !platform)
438
+ return;
439
+ setPulls(null);
440
+ const list = await fetchOpenPrs(preview, platform, { apiUrl });
441
+ if (list)
442
+ setPulls(list);
443
+ else {
444
+ setPulls([]);
445
+ setError('Couldn’t load the open pull requests. Try again in a moment.');
446
+ }
447
+ }
448
+ /** Opens another PR's latest build in this app, the same way a newer build opens. */
449
+ function switchPr(pr) {
450
+ if (!pr.openUrl)
451
+ return;
452
+ haptics.selection();
453
+ close();
454
+ Linking.openURL(pr.openUrl);
455
+ }
456
+ /** After a report is sent: a failed step moves the plan on; the next tap carries on there. */
457
+ function finishReport() {
458
+ if (failing) {
459
+ const scenario = checklist?.scenarios.find((s) => s.key === failing.scenarioKey);
460
+ const id = stepId(failing.scenarioKey, failing.step.index);
461
+ const after = { ...results, [id]: 'fail' };
462
+ setOverrides((current) => ({ ...current, [id]: 'fail' }));
463
+ if (scenario) {
464
+ const next = nextOpenStep(scenario, after, failing.position + 1);
465
+ go(next >= 0
466
+ ? { view: 'step', key: scenario.key, index: next }
467
+ : { view: 'done', key: scenario.key });
468
+ }
469
+ }
470
+ setScreenshot(null);
471
+ setFailing(null);
472
+ setPhase('idle');
473
+ void refresh();
474
+ }
475
+ const placement = popoverPlacement(anchor ?? defaultPosition(bounds), bounds);
476
+ const showButton = phase === 'idle' || phase === 'loading' || (phase === 'open' && !full);
477
+ return (_jsxs(Overlay, { children: [toast && phase !== 'open' && phase !== 'composing' && phase !== 'capturing' ? (_jsx(NewBuildToast, { build: toast, onPress: toggle })) : null, phase === 'open' && checklist ? (_jsx(ReviewPopover, { staff: staffMode ? appLabel() : null, checklist: checklist, results: results, rechecked: rechecked, nav: nav, direction: direction, onNav: (next, dir) => {
478
+ haptics.selection();
479
+ go(next, dir);
480
+ }, placement: placement, bounds: bounds, full: full, onToggleFull: () => {
481
+ haptics.light();
482
+ setFull((current) => !current);
483
+ }, onClose: close, canSetUp: canSetUp, setup: setup, onSetUp: setUp, onStart: (key) => {
484
+ haptics.selection();
485
+ setSetup(null);
486
+ begin(key);
487
+ }, onPass: pass, onFail: fail, onRecheck: recheck, onReportOther: () => void startReport(null), ideas: ideas, onShareIdea: () => void startReport(null, 'idea'), onAnswerIdea: (id, answers) => void answer(id, answers), onIdeaAction: (id, action) => void act(id, action), ideaBusy: ideaBusy, pulls: pulls, onShowPulls: () => void showPulls(), onSwitchPr: switchPr, name: name, onNameChange: (next) => {
488
+ setName(next);
489
+ rememberedName = next.trim();
490
+ }, error: error })) : null, showButton ? (_jsx(Bubble, { onPress: toggle, badge: reviewBadge(checklist) ?? (ideasNeedingYou(ideas) ? { kind: 'idea' } : null), busy: phase === 'loading', active: phase === 'open', onRest: setAnchor })) : null, phase === 'composing' && screenshot ? _jsx(CaptureFlash, {}) : null, phase === 'composing' ? (_jsx(Composer, { screenshot: screenshot, route: pathname, branch: preview?.branch, staff: staffMode ? appLabel() : null, onSendStaff: (input) => sendStaffReport({
491
+ appKey: appKey ?? '',
492
+ platform,
493
+ report: {
494
+ ...input,
495
+ route: pathname,
496
+ device: deviceInfo(),
497
+ app: appVersion(),
498
+ diagnostics: diagnostics.snapshot(),
499
+ },
500
+ }, { apiUrl }), device: deviceLabel(), hasScenario: Boolean(controller.lastScenarioId()), failedStep: failing?.step ?? null, kind: composerKind, onKindChange: setComposerKind, onSendIdea: (draft) => submitIdea({
501
+ caller,
502
+ platform,
503
+ draft: { ...draft, route: pathname, device: deviceInfo() },
504
+ }, { apiUrl }), onViewIdea: viewIdea, onSend: async (input) => {
505
+ if (!preview)
506
+ return { ok: false, message: 'This build isn’t a PR preview.' };
507
+ const result = await sendFeedback({
508
+ ...input,
509
+ body: failing ? failedStepBody(failing.step, input.body) : input.body,
510
+ // A failed step in the PR's own plan is the PR's to fix.
511
+ ...(failing ? { cause: 'pr' } : {}),
512
+ preview,
513
+ platform,
514
+ route: pathname,
515
+ device: deviceInfo(),
516
+ diagnostics: diagnostics.snapshot(),
517
+ ...(controller.lastScenarioId() ? { scenarioId: controller.lastScenarioId() } : {}),
518
+ }, { apiUrl });
519
+ if (result.ok && failing) {
520
+ await sendStepResult({
521
+ preview,
522
+ platform,
523
+ scenarioKey: failing.scenarioKey,
524
+ stepIndex: failing.step.index,
525
+ result: 'fail',
526
+ ...(input.reporterName ? { reporterName: input.reporterName } : {}),
527
+ ...(result.findingId ? { findingId: result.findingId } : {}),
528
+ }, { apiUrl });
529
+ }
530
+ return result;
531
+ }, onSetCause: (findingId, cause) => preview
532
+ ? sendCause({ preview, platform, findingId, cause }, { apiUrl })
533
+ : Promise.resolve({ ok: false, message: 'Not a PR preview.' }), onFileIssue: (findingId) => preview
534
+ ? fileIssue({ preview, platform, findingId }, { apiUrl })
535
+ : Promise.resolve({
536
+ ok: false,
537
+ message: 'Not a PR preview.',
538
+ openInBrowser: false,
539
+ }), onRequestFix: (findingId) => !preview
540
+ ? Promise.resolve({ ok: false, message: 'Not a PR preview.' })
541
+ : requestFix({
542
+ preview,
543
+ platform,
544
+ findingId,
545
+ ...(rememberedName ? { reporterName: rememberedName } : {}),
546
+ }, { apiUrl }), onCancel: cancelReport, onClose: finishReport })) : null] }));
547
+ }
548
+ /**
549
+ * A floating glass sheet that rises from the bottom edge and sits above the
550
+ * keyboard. It's a plain view in the overlay rather than a Modal, because on
551
+ * iOS a Modal can't present while the app is already showing a native modal
552
+ * screen. With `scrim`, the app behind dims and a tap on it closes the sheet.
553
+ */
554
+ function Sheet({ onClose, scrim = false, children, }) {
555
+ const { height } = useWindowDimensions();
556
+ const reduceMotion = useReduceMotion();
557
+ const shown = useRef(new Animated.Value(0)).current;
558
+ useBackPress(onClose);
559
+ useEffect(() => {
560
+ if (reduceMotion) {
561
+ shown.setValue(1);
562
+ return;
563
+ }
564
+ Animated.spring(shown, { toValue: 1, ...SPRING, useNativeDriver: true }).start();
565
+ }, [shown, reduceMotion]);
566
+ return (_jsxs(KeyboardAvoidingView, { style: [
567
+ StyleSheet.absoluteFill,
568
+ styles.backdrop,
569
+ { paddingTop: screenBounds(0, 0).insetTop },
570
+ ], behavior: Platform.OS === 'ios' ? 'padding' : undefined, children: [_jsx(Animated.View, { style: [StyleSheet.absoluteFill, scrim && styles.scrim, { opacity: shown }], children: _jsx(Pressable, { style: StyleSheet.absoluteFill, onPress: scrim ? onClose : undefined, accessible: scrim, accessibilityRole: "button", accessibilityLabel: "Close" }) }), _jsx(Animated.View, { style: [
571
+ styles.sheet,
572
+ // Slides only: fading a view that holds glass stops the glass rendering.
573
+ {
574
+ transform: [
575
+ {
576
+ translateY: shown.interpolate({
577
+ inputRange: [0, 1],
578
+ outputRange: [height * 0.6, 0],
579
+ }),
580
+ },
581
+ ],
582
+ },
583
+ ], accessibilityViewIsModal: true, children: _jsx(Surface, { radius: 38, style: styles.sheetSurface, children: children }) })] }));
584
+ }
585
+ /** Close or Done on the left, a centered title, and the main action on the right. */
586
+ function SheetHeader({ title, subtitle, left, right, }) {
587
+ const colors = useColors();
588
+ return (_jsxs(View, { style: styles.sheetHeader, children: [_jsx(View, { style: styles.sheetSide, children: left }), _jsxs(View, { style: styles.sheetTitle, children: [title ? (_jsx(Text, { style: [text.headline, styles.center, { color: colors.label }], numberOfLines: 1, children: title })) : null, subtitle ? (_jsx(Text, { style: [text.footnote, styles.center, { color: colors.secondary }], numberOfLines: 1, children: subtitle })) : null] }), _jsx(View, { style: [styles.sheetSide, styles.sheetSideRight], children: right })] }));
589
+ }
590
+ /** A short white flash as the screen is captured, like the system screenshot. */
591
+ function CaptureFlash() {
592
+ const opacity = useRef(new Animated.Value(0.85)).current;
593
+ const [done, setDone] = useState(false);
594
+ useEffect(() => {
595
+ Animated.timing(opacity, { toValue: 0, duration: 260, useNativeDriver: true }).start(() => setDone(true));
596
+ }, [opacity]);
597
+ if (done)
598
+ return null;
599
+ return (_jsx(Animated.View, { style: [StyleSheet.absoluteFill, styles.flash, { opacity }], pointerEvents: "none" }));
600
+ }
601
+ /** "A new build is ready" for a moment at the top, then it's just the bubble's dot. */
602
+ function NewBuildToast({ build, onPress }) {
603
+ const colors = useColors();
604
+ return (_jsx(View, { style: styles.toastWrap, pointerEvents: "box-none", children: _jsx(Pressable, { onPress: onPress, style: ({ pressed }) => [pressed && styles.pressed], accessibilityRole: "button", accessibilityLiveRegion: "polite", children: _jsxs(Surface, { radius: 24, style: styles.toast, children: [_jsx(View, { style: [styles.toastDot, { backgroundColor: COLORS.tape }] }), _jsx(Text, { style: [text.subhead, styles.strong, { color: colors.label }], children: build.hasFix ? 'Claude’s fix is ready to test' : 'A new build is ready' }), _jsx(Text, { style: [text.subhead, styles.strong, { color: colors.tint }], children: "View" })] }) }) }));
605
+ }
606
+ function Composer({ screenshot, route, branch, device, hasScenario, failedStep, kind, staff, onSendStaff, onKindChange, onSendIdea, onViewIdea, onSend, onSetCause, onRequestFix, onFileIssue, onCancel, onClose, }) {
607
+ const colors = useColors();
608
+ const { width, height } = useWindowDimensions();
609
+ const [body, setBody] = useState('');
610
+ const [name, setName] = useState(rememberedName);
611
+ const [email, setEmail] = useState(rememberedEmail);
612
+ const [includeScreenshot, setIncludeScreenshot] = useState(screenshot !== null);
613
+ const [annotations, setAnnotations] = useState([]);
614
+ const [annotating, setAnnotating] = useState(false);
615
+ const [status, setStatus] = useState({ kind: 'editing' });
616
+ const pr = branch?.match(/^pr-(\d+)$/)?.[1];
617
+ const sending = status.kind === 'sending';
618
+ const canSend = (failedStep !== null || body.trim() !== '') && !sending;
619
+ const withShot = includeScreenshot && screenshot !== null;
620
+ const idea = kind === 'idea' && failedStep === null;
621
+ async function sendIdea() {
622
+ rememberedName = name.trim();
623
+ rememberedEmail = email.trim();
624
+ setStatus({ kind: 'sending' });
625
+ const result = await onSendIdea({
626
+ body: body.trim(),
627
+ ...(name.trim() ? { authorName: name.trim() } : {}),
628
+ ...(email.trim() ? { authorEmail: email.trim() } : {}),
629
+ ...(withShot && screenshot ? { screenshot } : {}),
630
+ });
631
+ if (!result.ok) {
632
+ haptics.warning();
633
+ setStatus({ kind: 'error', message: result.message });
634
+ return;
635
+ }
636
+ haptics.success();
637
+ Keyboard.dismiss();
638
+ setStatus({ kind: 'idea_sent', idea: result.idea, emailed: email.trim() !== '' });
639
+ }
640
+ async function sendStaff() {
641
+ rememberedName = name.trim();
642
+ setStatus({ kind: 'sending' });
643
+ const result = await onSendStaff({
644
+ body: body.trim(),
645
+ ...(name.trim() ? { reporterName: name.trim() } : {}),
646
+ ...(withShot && screenshot ? { screenshot, annotations } : {}),
647
+ });
648
+ if (!result.ok) {
649
+ haptics.warning();
650
+ setStatus({ kind: 'error', message: result.message });
651
+ return;
652
+ }
653
+ haptics.success();
654
+ Keyboard.dismiss();
655
+ setStatus({ kind: 'filed', issueNumber: result.issueNumber, issueUrl: result.issueUrl });
656
+ }
657
+ async function send() {
658
+ if (idea)
659
+ return sendIdea();
660
+ if (staff)
661
+ return sendStaff();
662
+ rememberedName = name.trim();
663
+ setStatus({ kind: 'sending' });
664
+ const result = await onSend({
665
+ body: body.trim(),
666
+ ...(name.trim() ? { reporterName: name.trim() } : {}),
667
+ ...(withShot && screenshot ? { screenshot, annotations } : {}),
668
+ });
669
+ if (!result.ok) {
670
+ haptics.warning();
671
+ setStatus({ kind: 'error', message: result.message });
672
+ return;
673
+ }
674
+ haptics.success();
675
+ Keyboard.dismiss();
676
+ setStatus({
677
+ kind: 'sent',
678
+ commentUrl: result.commentUrl,
679
+ withScreenshot: withShot,
680
+ ...(result.findingId ? { findingId: result.findingId } : {}),
681
+ triage: {
682
+ cause: result.cause ?? 'unknown',
683
+ causeSource: result.causeSource ?? 'guess',
684
+ reasons: result.reasons ?? [],
685
+ ...(result.issueUrl ? { issueUrl: result.issueUrl } : {}),
686
+ },
687
+ });
688
+ }
689
+ const attached = [
690
+ withShot ? 'this screenshot' : null,
691
+ route === '/' ? 'the home screen' : `the screen (${route})`,
692
+ hasScenario ? 'the scenario’s setup' : null,
693
+ device,
694
+ 'recent logs',
695
+ ].filter(Boolean);
696
+ const attachedText = `Attached: ${attached.slice(0, -1).join(', ')} and ${attached.at(-1)}.`;
697
+ return (_jsxs(_Fragment, { children: [_jsx(Sheet, { onClose: status.kind === 'sent' || status.kind === 'idea_sent' || status.kind === 'filed'
698
+ ? onClose
699
+ : onCancel, scrim: status.kind === 'sent' || status.kind === 'idea_sent' || status.kind === 'filed', children: status.kind === 'filed' ? (_jsx(Filed, { issueNumber: status.issueNumber, issueUrl: status.issueUrl, onClose: onClose })) : status.kind === 'idea_sent' ? (_jsx(IdeaSent, { emailed: status.emailed, onView: () => onViewIdea(status.idea), onClose: onClose })) : status.kind === 'sent' ? (_jsx(Sent, { pr: pr, commentUrl: status.commentUrl, triage: status.triage, withScreenshot: status.withScreenshot, onSetCause: status.findingId
700
+ ? (cause) => onSetCause(status.findingId, cause)
701
+ : undefined, onRequestFix: status.findingId ? () => onRequestFix(status.findingId) : undefined, onFileIssue: status.findingId ? () => onFileIssue(status.findingId) : undefined, onClose: onClose })) : (_jsxs(_Fragment, { children: [_jsx(SheetHeader, { title: failedStep ? 'Step Failed' : idea ? 'Share an Idea' : 'Report an Issue', subtitle: staff ?? (pr ? `PR #${pr}` : undefined), left: _jsx(CircleButton, { icon: "xmark", label: "Cancel", onPress: onCancel, disabled: sending }), right: _jsx(CircleButton, { icon: "arrow.up", label: idea ? 'Send idea to Claude' : staff ? 'File as a GitHub issue' : 'Send to PR', tint: true, onPress: send, disabled: !canSend, busy: sending }) }), _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", style: styles.sheetScroll, contentContainerStyle: styles.form, children: [failedStep ? null : (_jsx(Segmented, { label: "What you're sending", value: kind, onChange: onKindChange, options: [
702
+ { value: 'issue', title: 'Something’s Wrong' },
703
+ { value: 'idea', title: 'Idea' },
704
+ ] })), failedStep ? (_jsxs(View, { style: styles.quote, children: [_jsx(View, { style: [styles.quoteMark, { backgroundColor: colors.fail }], children: _jsx(Icon, { name: "xmark", size: 10, color: "#FFFFFF", weight: "bold" }) }), _jsxs(View, { style: styles.quoteText, children: [_jsx(Text, { style: [text.subhead, styles.strong, { color: colors.label }], children: failedStep.action }), _jsxs(Text, { style: [text.footnote, { color: colors.secondary }], children: ["Expected: ", failedStep.expect] })] })] })) : null, _jsx(TextInput, { value: body, onChangeText: setBody, placeholder: failedStep
705
+ ? 'What happened instead? (optional)'
706
+ : idea
707
+ ? 'What should the app do? Say what you’d change, and where.'
708
+ : 'What’s wrong? What did you expect?', placeholderTextColor: colors.secondary, multiline: true, autoFocus: true, style: [
709
+ text.body,
710
+ styles.note,
711
+ { color: colors.label, backgroundColor: colors.fill },
712
+ ], accessibilityLabel: idea ? 'Your idea' : "What's wrong" }), status.kind === 'error' ? (_jsx(Text, { style: [text.footnote, styles.inset, { color: colors.fail }], accessibilityRole: "alert", children: status.message })) : null, _jsxs(View, { style: styles.attach, children: [screenshot ? (_jsxs(Pressable, { onPress: () => setIncludeScreenshot((on) => !on), accessibilityRole: "switch", accessibilityLabel: "Include screenshot", accessibilityState: { checked: includeScreenshot }, style: [
713
+ styles.thumb,
714
+ {
715
+ height: Math.round((THUMB_WIDTH * height) / Math.max(width, 1)),
716
+ borderColor: colors.separator,
717
+ },
718
+ !includeScreenshot && styles.thumbOff,
719
+ ], children: [_jsx(Image, { source: { uri: `data:${screenshot.contentType};base64,${screenshot.base64}` }, style: StyleSheet.absoluteFill, resizeMode: "cover", accessibilityLabel: "Screenshot of the current screen" }), _jsx(View, { style: styles.thumbX, children: _jsx(Icon, { name: includeScreenshot ? 'xmark' : 'arrow.clockwise', size: 8, color: "#FFFFFF", weight: "bold" }) })] })) : null, _jsxs(View, { style: styles.attachMeta, children: [withShot && !idea ? (_jsx(Pressable, { onPress: () => setAnnotating(true), accessibilityRole: "button", style: ({ pressed }) => [
720
+ styles.pill,
721
+ { backgroundColor: colors.tintFill },
722
+ pressed && styles.pressed,
723
+ ], children: _jsx(Text, { style: [text.subhead, styles.strong, { color: colors.tint }], children: annotations.length ? `Mark Up (${annotations.length})` : 'Mark Up' }) })) : null, _jsx(Text, { style: [text.footnote, { color: colors.secondary }], children: attachedText })] })] }), _jsx(View, { style: styles.inset, children: _jsx(ReviewerName, { name: name, onChange: setName, colors: colors }) }), idea ? (_jsxs(_Fragment, { children: [_jsx(TextInput, { value: email, onChangeText: setEmail, placeholder: "Email for updates (optional)", placeholderTextColor: colors.secondary, keyboardType: "email-address", autoCapitalize: "none", autoCorrect: false, style: [
724
+ text.subhead,
725
+ styles.email,
726
+ { color: colors.label, backgroundColor: colors.fill },
727
+ ], accessibilityLabel: "Email for updates" }), _jsx(Text, { style: [text.footnote, styles.inset, { color: colors.secondary }], children: "Claude reads the code and writes this up as a GitHub issue, asking you first if it needs to. Then anyone on the team can tap Build It, and you get a build to test." })] })) : staff ? (_jsx(Text, { style: [text.footnote, styles.inset, { color: colors.secondary }], children: "Sending opens a GitHub issue with what you wrote, the screenshot, this screen, the app\u2019s version and recent errors." })) : (_jsxs(Text, { style: [text.footnote, styles.inset, { color: colors.secondary }], children: ["Sending posts this as a comment on ", pr ? `PR #${pr}` : 'the PR', ". If it was already broken before this PR, you can file it as a GitHub issue next."] }))] })] })) }), annotating && screenshot ? (_jsx(Annotator, { screenshot: screenshot, initial: annotations, onDone: (boxes) => {
728
+ setAnnotations(boxes);
729
+ setAnnotating(false);
730
+ }, onCancel: () => setAnnotating(false) })) : null] }));
731
+ }
732
+ const THUMB_WIDTH = 55;
733
+ /** Two choices in one capsule; neither is picked until the cause is known. */
734
+ function Segmented({ options, value, onChange, label, }) {
735
+ const colors = useColors();
736
+ return (_jsx(View, { style: [styles.segments, { backgroundColor: colors.fill }], accessibilityRole: "radiogroup", accessibilityLabel: label, children: options.map((option) => {
737
+ const on = option.value === value;
738
+ return (_jsx(Pressable, { onPress: () => {
739
+ if (on)
740
+ return;
741
+ haptics.selection();
742
+ onChange(option.value);
743
+ }, accessibilityRole: "radio", accessibilityState: { checked: on }, style: [styles.segment, on && [styles.segmentOn, { backgroundColor: colors.segment }]], children: _jsx(Text, { style: [
744
+ text.footnote,
745
+ styles.segmentText,
746
+ on && styles.strong,
747
+ { color: colors.label },
748
+ ], numberOfLines: 1, children: option.title }) }, option.value));
749
+ }) }));
750
+ }
751
+ /** What filing an issue did, said plainly: where it went and whether Claude is on it. */
752
+ function issueStatus(number, fix) {
753
+ const filed = `Created issue #${number}, linked from the PR.`;
754
+ if (fix === 'started')
755
+ return `${filed} It has the tapthrough:fix label, so Claude is starting a fix in its own PR.`;
756
+ if (fix === 'not_set_up')
757
+ return `${filed} Claude can’t fix it yet: Fix with Claude isn’t set up on this repo. The issue says what a developer needs to do.`;
758
+ if (fix === 'refused')
759
+ return `${filed} Claude didn’t start. The issue says why.`;
760
+ if (fix === 'off')
761
+ return `${filed} A developer can add the tapthrough:fix label to have Claude fix it.`;
762
+ return `${filed}`;
763
+ }
764
+ /** After a staff build's report: the issue it became. */
765
+ function Filed({ issueNumber, issueUrl, onClose, }) {
766
+ const colors = useColors();
767
+ return (_jsxs(_Fragment, { children: [_jsx(SheetHeader, { title: "Filed", left: _jsx(CircleButton, { icon: "xmark", label: "Done", onPress: onClose }) }), _jsxs(View, { style: [styles.form, styles.ideaSent], children: [_jsx(View, { style: [styles.ideaMark, { backgroundColor: colors.tintFill }], children: _jsx(Icon, { name: "checkmark", size: 22, color: colors.tint }) }), _jsxs(Text, { style: [text.subhead, styles.center, { color: colors.label }], children: ["Created issue #", issueNumber, " with your screenshot and details. The team sees it on GitHub."] }), _jsx(Capsule, { title: "View on GitHub", kind: "plain", onPress: () => Linking.openURL(issueUrl) }), _jsx(Capsule, { title: "Done", onPress: onClose })] })] }));
768
+ }
769
+ /** After an idea is sent: what happens next, and where to follow it. */
770
+ function IdeaSent({ emailed, onView, onClose, }) {
771
+ const colors = useColors();
772
+ return (_jsxs(_Fragment, { children: [_jsx(SheetHeader, { title: "Sent to Claude", left: _jsx(CircleButton, { icon: "xmark", label: "Done", onPress: onClose }) }), _jsxs(View, { style: [styles.form, styles.ideaSent], children: [_jsx(View, { style: [styles.ideaMark, { backgroundColor: colors.tintFill }], children: _jsx(Icon, { name: "lightbulb", size: 22, color: colors.tint }) }), _jsxs(Text, { style: [text.subhead, styles.center, { color: colors.label }], children: ["Claude is reading the code and writing your idea up. If it has questions, they show up under Ideas in the Review panel", emailed ? ', and in your email' : '', "."] }), _jsx(Capsule, { title: "View Idea", onPress: onView }), _jsx(Capsule, { title: "Done", kind: "plain", onPress: onClose })] })] }));
773
+ }
774
+ /**
775
+ * After a report is sent: whether it looks like the PR's bug, and the one tap
776
+ * that follows. A bug the PR didn't cause can be filed as a GitHub issue from here.
777
+ */
778
+ function Sent({ pr, commentUrl, triage, withScreenshot, onSetCause, onRequestFix, onFileIssue, onClose, }) {
779
+ const colors = useColors();
780
+ const [cause, setCause] = useState(triage.cause);
781
+ const [fix, setFix] = useState('idle');
782
+ const [byReporter, setByReporter] = useState(triage.causeSource === 'reporter');
783
+ const [error, setError] = useState(null);
784
+ const [issue, setIssue] = useState({ state: 'idle' });
785
+ async function createIssue() {
786
+ if (!onFileIssue) {
787
+ if (triage.issueUrl)
788
+ Linking.openURL(triage.issueUrl);
789
+ return;
790
+ }
791
+ setIssue({ state: 'filing' });
792
+ setError(null);
793
+ const result = await onFileIssue();
794
+ if (result.ok) {
795
+ haptics.success();
796
+ setIssue({
797
+ state: 'filed',
798
+ number: result.issueNumber,
799
+ url: result.issueUrl,
800
+ fix: result.fix,
801
+ });
802
+ return;
803
+ }
804
+ setIssue({ state: 'idle' });
805
+ // Tapthrough can't file it here (old server, or no Issues permission yet): GitHub's own form still works.
806
+ if (result.openInBrowser && triage.issueUrl) {
807
+ Linking.openURL(triage.issueUrl);
808
+ return;
809
+ }
810
+ setError(result.message);
811
+ }
812
+ async function askFix() {
813
+ if (!onRequestFix)
814
+ return;
815
+ setFix('asking');
816
+ setError(null);
817
+ const result = await onRequestFix();
818
+ if (result.ok) {
819
+ setFix(result.status);
820
+ }
821
+ else {
822
+ setFix('idle');
823
+ setError(result.message);
824
+ }
825
+ }
826
+ async function choose(next) {
827
+ if (!onSetCause)
828
+ return;
829
+ const previous = cause;
830
+ setCause(next);
831
+ setByReporter(true);
832
+ setError(null);
833
+ const result = await onSetCause(next);
834
+ if (!result.ok) {
835
+ setCause(previous);
836
+ setError(result.message);
837
+ }
838
+ }
839
+ const where = pr ? `PR #${pr}` : 'the PR';
840
+ const guess = cause !== 'unknown' && !byReporter && triage.reasons.length > 0
841
+ ? `Tapthrough’s guess: ${triage.reasons.join(' ')}`
842
+ : null;
843
+ const autoFix = triage.issueFix === 'auto';
844
+ const why = cause === 'unknown'
845
+ ? 'Did this PR cause it, or was it already broken?'
846
+ : cause === 'pr'
847
+ ? `It stays on ${where} as a comment for the author to fix. Claude can fix it on this PR’s branch.`
848
+ : autoFix
849
+ ? `It stays on ${where} as a note and won’t block the merge. A GitHub issue tracks it on its own, and Claude starts a fix for it in a separate PR.`
850
+ : `It stays on ${where} as a note and won’t block the merge. A GitHub issue tracks it on its own. A developer can add the tapthrough:fix label to have Claude fix it in a separate PR.`;
851
+ return (_jsxs(_Fragment, { children: [_jsx(SheetHeader, { right: _jsx(CircleButton, { icon: "checkmark", label: "Done", onPress: onClose }) }), _jsxs(View, { style: styles.sent, children: [_jsx(View, { style: [styles.bigMark, { backgroundColor: colors.pass }], children: _jsx(Icon, { name: "checkmark", size: 28, color: "#FFFFFF", weight: "bold" }) }), _jsxs(Text, { style: [text.title2, styles.center, { color: colors.label }], children: ["Sent to ", pr ? `PR #${pr}` : 'the PR'] }), _jsx(Text, { style: [text.subhead, styles.center, { color: colors.secondary }], children: withScreenshot ? 'Posted to the PR with your screenshot.' : 'Posted to the PR.' })] }), onSetCause ? (_jsx(Segmented, { label: "Cause", value: cause === 'unknown' ? null : cause, onChange: choose, options: [
852
+ { value: 'pr', title: 'Caused by this PR' },
853
+ { value: 'unrelated', title: 'Already broken' },
854
+ ] })) : null, _jsx(Text, { style: [text.footnote, styles.center, styles.why, { color: colors.secondary }], children: why }), guess ? (_jsx(Text, { style: [text.caption, styles.center, styles.why, { color: colors.tertiary }], children: guess })) : null, _jsxs(View, { style: styles.next, children: [cause === 'pr' && onRequestFix ? (fix === 'idle' ? (_jsx(Capsule, { title: "Ask Claude to Fix", onPress: askFix })) : (_jsxs(View, { style: [styles.status, { backgroundColor: colors.fill }], children: [fix === 'asking' ? _jsx(ActivityIndicator, { size: "small" }) : null, _jsx(Text, { style: [text.footnote, styles.statusText, { color: colors.label }], children: fix === 'asking'
855
+ ? 'Asking Claude…'
856
+ : fix === 'requested'
857
+ ? 'Claude is on it. When the fix builds, the Review button shows a dot.'
858
+ : 'Fix with Claude isn’t set up on this repo yet. The PR comment has the two setup steps for a developer.' })] }))) : null, cause === 'unrelated' && (onFileIssue || triage.issueUrl) ? (issue.state === 'filed' ? (_jsxs(_Fragment, { children: [_jsx(View, { style: [styles.status, { backgroundColor: colors.fill }], children: _jsx(Text, { style: [text.footnote, styles.statusText, { color: colors.label }], children: issueStatus(issue.number, issue.fix) }) }), _jsx(Capsule, { kind: "plain", title: `View Issue #${issue.number}`, onPress: () => Linking.openURL(issue.url) })] })) : (_jsx(Capsule, { title: autoFix ? 'Create Issue for Claude' : 'Create GitHub Issue', onPress: createIssue, busy: issue.state === 'filing' }))) : null, error ? (_jsx(Text, { style: [text.footnote, styles.center, { color: colors.fail }], accessibilityRole: "alert", children: error })) : null, _jsx(Capsule, { kind: "plain", title: "View on GitHub", onPress: () => Linking.openURL(commentUrl) })] })] }));
859
+ }
860
+ const styles = StyleSheet.create({
861
+ pressed: { opacity: 0.7 },
862
+ strong: { fontWeight: '600' },
863
+ center: { textAlign: 'center' },
864
+ inset: { paddingHorizontal: 8 },
865
+ backdrop: { justifyContent: 'flex-end' },
866
+ scrim: { backgroundColor: 'rgba(0, 0, 0, 0.14)' },
867
+ flash: { backgroundColor: '#FFFFFF' },
868
+ sheet: { marginHorizontal: 8, marginBottom: 8, flexShrink: 1 },
869
+ sheetSurface: { flexShrink: 1, padding: 8, overflow: 'hidden' },
870
+ sheetHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingBottom: 4 },
871
+ sheetSide: { width: 44 },
872
+ sheetSideRight: { alignItems: 'flex-end' },
873
+ sheetTitle: { flex: 1, minWidth: 0 },
874
+ sheetScroll: { flexGrow: 0, flexShrink: 1 },
875
+ form: { paddingHorizontal: 8, paddingTop: 6, paddingBottom: 12, gap: 14 },
876
+ quote: { flexDirection: 'row', gap: 12, alignItems: 'flex-start', paddingHorizontal: 8 },
877
+ quoteMark: {
878
+ width: 22,
879
+ height: 22,
880
+ borderRadius: 11,
881
+ marginTop: 1,
882
+ alignItems: 'center',
883
+ justifyContent: 'center',
884
+ },
885
+ quoteText: { flex: 1, gap: 2 },
886
+ note: {
887
+ minHeight: 104,
888
+ borderRadius: 20,
889
+ paddingHorizontal: 14,
890
+ paddingTop: 12,
891
+ paddingBottom: 12,
892
+ textAlignVertical: 'top',
893
+ },
894
+ email: { borderRadius: 14, paddingHorizontal: 12, paddingVertical: 10, marginHorizontal: 8 },
895
+ ideaSent: { alignItems: 'stretch', gap: 12, paddingTop: 8 },
896
+ ideaMark: {
897
+ width: 48,
898
+ height: 48,
899
+ borderRadius: 24,
900
+ alignItems: 'center',
901
+ justifyContent: 'center',
902
+ alignSelf: 'center',
903
+ },
904
+ attach: { flexDirection: 'row', gap: 14, alignItems: 'flex-start', paddingHorizontal: 8 },
905
+ thumb: {
906
+ width: THUMB_WIDTH,
907
+ borderRadius: 9,
908
+ overflow: 'hidden',
909
+ borderWidth: StyleSheet.hairlineWidth,
910
+ },
911
+ thumbOff: { opacity: 0.3 },
912
+ thumbX: {
913
+ position: 'absolute',
914
+ top: 4,
915
+ right: 4,
916
+ width: 18,
917
+ height: 18,
918
+ borderRadius: 9,
919
+ backgroundColor: 'rgba(0, 0, 0, 0.55)',
920
+ alignItems: 'center',
921
+ justifyContent: 'center',
922
+ },
923
+ attachMeta: { flex: 1, gap: 8, alignItems: 'flex-start' },
924
+ pill: { paddingVertical: 7, paddingHorizontal: 16, borderRadius: 999 },
925
+ sent: { alignItems: 'center', gap: 6, paddingHorizontal: 20 },
926
+ bigMark: {
927
+ width: 64,
928
+ height: 64,
929
+ borderRadius: 32,
930
+ alignItems: 'center',
931
+ justifyContent: 'center',
932
+ marginBottom: 8,
933
+ },
934
+ segments: {
935
+ flexDirection: 'row',
936
+ marginTop: 20,
937
+ marginHorizontal: 12,
938
+ padding: 3,
939
+ borderRadius: 999,
940
+ },
941
+ segment: { flex: 1, paddingVertical: 8, borderRadius: 999, alignItems: 'center' },
942
+ segmentOn: {
943
+ shadowColor: '#000000',
944
+ shadowOpacity: 0.12,
945
+ shadowRadius: 8,
946
+ shadowOffset: { width: 0, height: 3 },
947
+ elevation: 2,
948
+ },
949
+ segmentText: { fontSize: 14 },
950
+ why: { paddingHorizontal: 24, paddingTop: 10 },
951
+ next: { paddingHorizontal: 12, paddingTop: 16, paddingBottom: 10, gap: 6 },
952
+ status: {
953
+ flexDirection: 'row',
954
+ alignItems: 'center',
955
+ gap: 10,
956
+ paddingVertical: 14,
957
+ paddingHorizontal: 16,
958
+ borderRadius: 22,
959
+ },
960
+ statusText: { flex: 1 },
961
+ toastWrap: {
962
+ position: 'absolute',
963
+ top: Platform.OS === 'ios' ? 58 : 28,
964
+ left: 0,
965
+ right: 0,
966
+ alignItems: 'center',
967
+ },
968
+ toast: {
969
+ flexDirection: 'row',
970
+ alignItems: 'center',
971
+ gap: 8,
972
+ paddingVertical: 12,
973
+ paddingHorizontal: 18,
974
+ },
975
+ toastDot: { width: 8, height: 8, borderRadius: 4 },
976
+ });
977
+ //# sourceMappingURL=Feedback.js.map