yaver-feedback-react-native 0.8.11 → 0.8.13

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.
@@ -0,0 +1,362 @@
1
+ // VibeChatScreen — the converged chat UI for the standalone feedback
2
+ // SDK. Mirrors Yaver mobile's Tasks tab + the in-Yaver native pane:
3
+ //
4
+ // 1. User sees a live SSE transcript of agent stdout (PhaseStatusLine
5
+ // style "searching… / compiling…" while running, full markdown
6
+ // output once it lands).
7
+ // 2. User can keep vibing — type a follow-up after the first turn
8
+ // lands and POST a /tasks/{id}/resume to multi-turn the same
9
+ // coding session.
10
+ // 3. Reload button at the bottom hits client.reloadApp() so the user
11
+ // can see the change without leaving the chat.
12
+ //
13
+ // State machine:
14
+ // idle — empty, waiting for first prompt (handled by parent screen)
15
+ // running — task is live, transcript streams, follow-up disabled
16
+ // done — task finished, follow-up enabled, Reload prominent
17
+ // failed — same as done but error tinted
18
+
19
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
20
+ import {
21
+ ActivityIndicator,
22
+ ScrollView,
23
+ StyleSheet,
24
+ Text,
25
+ TextInput,
26
+ TouchableOpacity,
27
+ View,
28
+ } from 'react-native';
29
+ import type { P2PClient } from './P2PClient';
30
+
31
+ export type VibeTurnRole = 'user' | 'assistant' | 'status';
32
+
33
+ export interface VibeTurn {
34
+ id: string;
35
+ role: VibeTurnRole;
36
+ text: string;
37
+ timestamp: number;
38
+ }
39
+
40
+ interface Props {
41
+ client: P2PClient;
42
+ initialTaskId: string;
43
+ initialUserPrompt: string;
44
+ onClose?: () => void;
45
+ /** Called when the user taps Reload after a task completes — uses
46
+ * P2PClient.reloadApp() with the active project context. */
47
+ onReload?: () => Promise<void>;
48
+ }
49
+
50
+ export function VibeChatScreen({
51
+ client,
52
+ initialTaskId,
53
+ initialUserPrompt,
54
+ onClose,
55
+ onReload,
56
+ }: Props) {
57
+ const [taskId, setTaskId] = useState(initialTaskId);
58
+ const [turns, setTurns] = useState<VibeTurn[]>(() => [
59
+ {
60
+ id: `user-${Date.now()}`,
61
+ role: 'user',
62
+ text: initialUserPrompt,
63
+ timestamp: Date.now(),
64
+ },
65
+ {
66
+ id: `status-${Date.now()}`,
67
+ role: 'status',
68
+ text: 'starting…',
69
+ timestamp: Date.now(),
70
+ },
71
+ ]);
72
+ const [streamBuffer, setStreamBuffer] = useState('');
73
+ const [status, setStatus] = useState<'running' | 'done' | 'failed'>('running');
74
+ const [followUp, setFollowUp] = useState('');
75
+ const [isResuming, setIsResuming] = useState(false);
76
+ const [isReloading, setIsReloading] = useState(false);
77
+ const scrollRef = useRef<ScrollView | null>(null);
78
+ const abortRef = useRef<(() => void) | null>(null);
79
+
80
+ // Subscribe to the current task's SSE stream. Re-runs whenever the
81
+ // taskId changes (resumeTask reuses the same id, so this only fires
82
+ // once per task — which is fine).
83
+ useEffect(() => {
84
+ let live = true;
85
+ const acc: string[] = [];
86
+ const close = client.streamTaskOutput(
87
+ taskId,
88
+ (line) => {
89
+ if (!live) return;
90
+ // Filter our internal error sentinel from the SSE helper.
91
+ if (line.startsWith('__error__:')) {
92
+ setStatus('failed');
93
+ setStreamBuffer((prev) => prev + (prev ? '\n' : '') + line.slice('__error__:'.length).trim());
94
+ return;
95
+ }
96
+ acc.push(line);
97
+ // Throttle re-renders: flush every ~100ms.
98
+ setStreamBuffer(acc.join('\n'));
99
+ },
100
+ (terminal) => {
101
+ if (!live) return;
102
+ setStatus(terminal === 'completed' ? 'done' : 'failed');
103
+ // Move the buffered stream into a real assistant turn so the
104
+ // user sees a stable render and can scroll back, then clear
105
+ // the buffer for any follow-up.
106
+ setTurns((prev) => {
107
+ const collapsed = acc.join('\n').trim();
108
+ if (!collapsed) return prev.filter((t) => t.role !== 'status');
109
+ const next = prev.filter((t) => t.role !== 'status');
110
+ next.push({
111
+ id: `assistant-${taskId}-${Date.now()}`,
112
+ role: 'assistant',
113
+ text: collapsed,
114
+ timestamp: Date.now(),
115
+ });
116
+ return next;
117
+ });
118
+ setStreamBuffer('');
119
+ },
120
+ );
121
+ abortRef.current = close;
122
+ return () => {
123
+ live = false;
124
+ try { close(); } catch { /* ignore */ }
125
+ };
126
+ }, [client, taskId]);
127
+
128
+ // Auto-scroll the transcript when new content lands.
129
+ useEffect(() => {
130
+ const t = setTimeout(() => {
131
+ scrollRef.current?.scrollToEnd({ animated: true });
132
+ }, 50);
133
+ return () => clearTimeout(t);
134
+ }, [streamBuffer, turns]);
135
+
136
+ const handleSendFollowUp = useCallback(async () => {
137
+ const text = followUp.trim();
138
+ if (!text || isResuming) return;
139
+ setIsResuming(true);
140
+ // Add user turn immediately for snappy UX.
141
+ setTurns((prev) => [
142
+ ...prev,
143
+ { id: `user-${Date.now()}`, role: 'user', text, timestamp: Date.now() },
144
+ { id: `status-${Date.now()}`, role: 'status', text: 'thinking…', timestamp: Date.now() },
145
+ ]);
146
+ setFollowUp('');
147
+ setStatus('running');
148
+ setStreamBuffer('');
149
+ try {
150
+ await client.resumeTask({ taskId, userPrompt: text });
151
+ // resumeTask reuses the same taskId, so the SSE subscription
152
+ // above will pick up the new output stream automatically. To
153
+ // force a fresh subscription we momentarily flip taskId to a
154
+ // sentinel and back; cleaner than tearing down + re-attaching
155
+ // the SSE manually.
156
+ const same = taskId;
157
+ setTaskId(`${same}#`);
158
+ setTimeout(() => setTaskId(same), 0);
159
+ } catch (e) {
160
+ setStatus('failed');
161
+ setTurns((prev) => [
162
+ ...prev.filter((t) => t.role !== 'status'),
163
+ {
164
+ id: `assistant-err-${Date.now()}`,
165
+ role: 'assistant',
166
+ text: `Failed to send follow-up: ${e instanceof Error ? e.message : String(e)}`,
167
+ timestamp: Date.now(),
168
+ },
169
+ ]);
170
+ } finally {
171
+ setIsResuming(false);
172
+ }
173
+ }, [client, followUp, isResuming, taskId]);
174
+
175
+ const handleReload = useCallback(async () => {
176
+ if (isReloading || !onReload) return;
177
+ setIsReloading(true);
178
+ try {
179
+ await onReload();
180
+ } catch (e) {
181
+ setTurns((prev) => [
182
+ ...prev,
183
+ {
184
+ id: `assistant-reload-err-${Date.now()}`,
185
+ role: 'assistant',
186
+ text: `Reload failed: ${e instanceof Error ? e.message : String(e)}`,
187
+ timestamp: Date.now(),
188
+ },
189
+ ]);
190
+ } finally {
191
+ setIsReloading(false);
192
+ }
193
+ }, [isReloading, onReload]);
194
+
195
+ return (
196
+ <View style={styles.container}>
197
+ <View style={styles.header}>
198
+ <Text style={styles.title}>Vibe</Text>
199
+ {onClose && (
200
+ <TouchableOpacity onPress={onClose} accessibilityLabel="Close vibe chat">
201
+ <Text style={styles.close}>✕</Text>
202
+ </TouchableOpacity>
203
+ )}
204
+ </View>
205
+
206
+ <ScrollView
207
+ ref={scrollRef}
208
+ style={styles.transcript}
209
+ contentContainerStyle={styles.transcriptContent}
210
+ keyboardShouldPersistTaps="handled"
211
+ >
212
+ {turns.map((turn) => (
213
+ <View
214
+ key={turn.id}
215
+ style={[
216
+ styles.turn,
217
+ turn.role === 'user' && styles.turnUser,
218
+ turn.role === 'assistant' && styles.turnAssistant,
219
+ turn.role === 'status' && styles.turnStatus,
220
+ ]}
221
+ >
222
+ <Text style={styles.turnText}>{turn.text}</Text>
223
+ </View>
224
+ ))}
225
+ {/* Live streaming buffer rendered as a single trailing
226
+ assistant block while the task is running. Once the task
227
+ terminates the stream is moved into a real turn (above)
228
+ and this block clears. */}
229
+ {streamBuffer && status === 'running' && (
230
+ <View style={[styles.turn, styles.turnAssistant]}>
231
+ <Text style={styles.turnText}>{streamBuffer}</Text>
232
+ </View>
233
+ )}
234
+ {status === 'running' && (
235
+ <View style={styles.spinnerRow}>
236
+ <ActivityIndicator size="small" color="#9ca3af" />
237
+ <Text style={styles.spinnerText}>working…</Text>
238
+ </View>
239
+ )}
240
+ </ScrollView>
241
+
242
+ <View style={styles.footer}>
243
+ <TextInput
244
+ style={styles.input}
245
+ value={followUp}
246
+ onChangeText={setFollowUp}
247
+ placeholder={status === 'running' ? 'wait for the agent…' : 'follow up…'}
248
+ placeholderTextColor="#666"
249
+ editable={status !== 'running' && !isResuming}
250
+ multiline
251
+ />
252
+ <View style={styles.actions}>
253
+ {onReload && (
254
+ <TouchableOpacity
255
+ style={[
256
+ styles.actionBtn,
257
+ styles.reloadBtn,
258
+ (isReloading || status === 'running') && styles.actionBtnDisabled,
259
+ ]}
260
+ onPress={handleReload}
261
+ disabled={isReloading || status === 'running'}
262
+ >
263
+ <Text style={styles.actionText}>
264
+ {isReloading ? 'reloading…' : '⟳ reload'}
265
+ </Text>
266
+ </TouchableOpacity>
267
+ )}
268
+ <TouchableOpacity
269
+ style={[
270
+ styles.actionBtn,
271
+ styles.sendBtn,
272
+ (isResuming || status === 'running' || !followUp.trim()) && styles.actionBtnDisabled,
273
+ ]}
274
+ onPress={handleSendFollowUp}
275
+ disabled={isResuming || status === 'running' || !followUp.trim()}
276
+ >
277
+ <Text style={styles.actionText}>
278
+ {isResuming ? '…' : '↑ send'}
279
+ </Text>
280
+ </TouchableOpacity>
281
+ </View>
282
+ </View>
283
+ </View>
284
+ );
285
+ }
286
+
287
+ const styles = StyleSheet.create({
288
+ container: { flex: 1, backgroundColor: '#0a0a0a' },
289
+ header: {
290
+ flexDirection: 'row',
291
+ alignItems: 'center',
292
+ justifyContent: 'space-between',
293
+ paddingHorizontal: 16,
294
+ paddingTop: 14,
295
+ paddingBottom: 8,
296
+ borderBottomWidth: 1,
297
+ borderBottomColor: 'rgba(255,255,255,0.08)',
298
+ },
299
+ title: { color: '#fff', fontSize: 17, fontWeight: '600' },
300
+ close: { color: '#9ca3af', fontSize: 18 },
301
+ transcript: { flex: 1 },
302
+ transcriptContent: { padding: 12, paddingBottom: 24 },
303
+ turn: {
304
+ marginVertical: 4,
305
+ padding: 10,
306
+ borderRadius: 12,
307
+ maxWidth: '92%',
308
+ },
309
+ turnUser: {
310
+ backgroundColor: '#7582f5',
311
+ alignSelf: 'flex-end',
312
+ },
313
+ turnAssistant: {
314
+ backgroundColor: 'rgba(255,255,255,0.06)',
315
+ borderColor: 'rgba(255,255,255,0.10)',
316
+ borderWidth: 1,
317
+ alignSelf: 'flex-start',
318
+ },
319
+ turnStatus: {
320
+ backgroundColor: 'transparent',
321
+ alignSelf: 'flex-start',
322
+ paddingHorizontal: 4,
323
+ },
324
+ turnText: { color: '#f1f5f9', fontSize: 14, lineHeight: 20 },
325
+ spinnerRow: {
326
+ flexDirection: 'row',
327
+ alignItems: 'center',
328
+ marginTop: 8,
329
+ paddingHorizontal: 4,
330
+ },
331
+ spinnerText: { color: '#9ca3af', fontSize: 12, marginLeft: 8 },
332
+ footer: {
333
+ borderTopWidth: 1,
334
+ borderTopColor: 'rgba(255,255,255,0.08)',
335
+ padding: 10,
336
+ },
337
+ input: {
338
+ minHeight: 40,
339
+ maxHeight: 120,
340
+ color: '#f1f5f9',
341
+ fontSize: 14,
342
+ backgroundColor: 'rgba(255,255,255,0.04)',
343
+ borderRadius: 10,
344
+ paddingHorizontal: 12,
345
+ paddingVertical: 8,
346
+ },
347
+ actions: {
348
+ flexDirection: 'row',
349
+ justifyContent: 'flex-end',
350
+ marginTop: 8,
351
+ },
352
+ actionBtn: {
353
+ paddingHorizontal: 14,
354
+ paddingVertical: 8,
355
+ borderRadius: 10,
356
+ marginLeft: 8,
357
+ },
358
+ actionBtnDisabled: { opacity: 0.5 },
359
+ reloadBtn: { backgroundColor: 'rgba(255,255,255,0.08)' },
360
+ sendBtn: { backgroundColor: '#7582f5' },
361
+ actionText: { color: '#fff', fontSize: 13, fontWeight: '600' },
362
+ });
@@ -815,6 +815,16 @@ export class YaverFeedback {
815
815
  return config;
816
816
  }
817
817
 
818
+ /** Returns the resolved relay password the SDK is currently using.
819
+ * Empty string when no relay routing is in play (direct LAN agent
820
+ * URLs need no password). Callers attaching it to relay-routed
821
+ * HTTP requests must check for empty before setting the header,
822
+ * since "X-Relay-Password: " is treated as invalid by the relay
823
+ * and would 401 the request. */
824
+ static getRelayPassword(): string {
825
+ return p2pRelayPassword;
826
+ }
827
+
818
828
  /**
819
829
  * Manually attach an error with optional metadata.
820
830
  * Use this in catch blocks to give the agent extra context.
@@ -1040,7 +1050,7 @@ export class YaverFeedback {
1040
1050
  errors: errorBuffer.length > 0 ? [...errorBuffer] : undefined,
1041
1051
  };
1042
1052
 
1043
- await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
1053
+ await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle, p2pRelayPassword);
1044
1054
  console.log('[YaverFeedback] Auto-report sent');
1045
1055
  } catch (err) {
1046
1056
  console.warn('[YaverFeedback] Auto-report failed:', err);
@@ -0,0 +1,102 @@
1
+ // buildFeedbackPrompt — shared prompt enrichment used by every Yaver
2
+ // feedback surface, in-Yaver native pane (mirrored in Swift + Kotlin)
3
+ // AND the standalone RN feedback SDK (this file). Keep all three
4
+ // implementations in lockstep — the wording is what the AI on the
5
+ // remote is conditioned to expect.
6
+ //
7
+ // The bare user text on its own loses crucial context: WHICH app the
8
+ // user is testing, WHICH screen they're looking at, and whether a
9
+ // screenshot is attached for visual reference. Without that the agent
10
+ // guesses, edits the wrong project, or asks clarifying questions
11
+ // instead of acting. The wrapper below tells the agent:
12
+ // - this feedback comes from the in-app drawer while the user is
13
+ // mid-test,
14
+ // - which project the user is in (when known),
15
+ // - that the FIRST attached image (when present) is a snapshot of
16
+ // the current screen — open it to see what the user is pointing
17
+ // at,
18
+ // - that changes should be applied to that project's source +
19
+ // saved so the user can trigger a Hermes reload to see them.
20
+ //
21
+ // Cross-reference: mobile/ios/Yaver/YaverFeedbackPane.swift's
22
+ // `buildFeedbackPrompt` and mobile/android/.../YaverFeedbackPane.kt's
23
+ // `buildFeedbackPrompt`. All three must match.
24
+
25
+ export interface BuildFeedbackPromptInput {
26
+ userPrompt: string;
27
+ /** Hot-Reload project name when running inside Yaver mobile, OR
28
+ * the host app's bundle/package name when running standalone. */
29
+ projectName?: string;
30
+ /** Absolute path on the host where the project lives (only known
31
+ * when running inside Yaver mobile via Hot Reload). */
32
+ projectPath?: string;
33
+ /** True when the caller has attached a screenshot of the current
34
+ * screen as the first image in the task's images array. */
35
+ hasScreenshot: boolean;
36
+ }
37
+
38
+ export function buildFeedbackPrompt(input: BuildFeedbackPromptInput): string {
39
+ const userPrompt = input.userPrompt ?? "";
40
+ const projectName = (input.projectName ?? "").trim();
41
+ const projectPath = (input.projectPath ?? "").trim();
42
+ const hasScreenshot = !!input.hasScreenshot;
43
+
44
+ const lines: string[] = [];
45
+ lines.push("[Mobile feedback from inside Yaver]");
46
+ lines.push(
47
+ "The user is providing this feedback while running a mobile app inside the Yaver mobile container " +
48
+ "and is currently looking at a specific screen of that app."
49
+ );
50
+ lines.push("");
51
+ if (projectName || projectPath) {
52
+ lines.push("App being tested:");
53
+ if (projectName) lines.push(` name: ${projectName}`);
54
+ if (projectPath) lines.push(` path: ${projectPath}`);
55
+ lines.push("");
56
+ }
57
+ if (hasScreenshot) {
58
+ lines.push(
59
+ "A screenshot of the current screen is attached as the first image. " +
60
+ "Open it before deciding what to change — the user is pointing at what they SEE, " +
61
+ "not necessarily what is named most prominently in the source."
62
+ );
63
+ lines.push("");
64
+ } else {
65
+ lines.push("(The user chose not to attach a screenshot for this round.)");
66
+ lines.push("");
67
+ }
68
+ lines.push("Operation contract:");
69
+ lines.push(
70
+ "1. Locate the file(s) responsible for what the user described and EDIT them in place. " +
71
+ "Save the changes — that is the deliverable."
72
+ );
73
+ lines.push(
74
+ "2. Stream a CONCISE Claude-Code / Codex-style narration as you work: " +
75
+ "one short line per step (e.g. \"Reading app/index.tsx\", " +
76
+ "\"Editing safe.backgroundColor\", \"Saved app/index.tsx\"). Show small diffs only — " +
77
+ "never dump entire files, never paste node_modules contents, never echo build / install logs."
78
+ );
79
+ lines.push(
80
+ "3. Do NOT run npm install / yarn / pnpm / git clone / cargo build / docker pull or any other " +
81
+ "long-running install / fetch command. The repo is already prepared on this machine. " +
82
+ "If a dependency is genuinely missing, say so in one line and stop — the user will install it."
83
+ );
84
+ lines.push(
85
+ "4. Do NOT trigger a Hermes reload yourself. The user has a Reload button in the drawer " +
86
+ "and decides when to refresh."
87
+ );
88
+ lines.push(
89
+ "5. Keep total output under a few hundred lines. Heavy ripgrep / find / cat with no filter " +
90
+ "are usually the wrong tool — use targeted reads."
91
+ );
92
+ if (!projectName && !projectPath) {
93
+ lines.push(
94
+ "6. If you can identify the project from the prompt or the screenshot, work there. " +
95
+ "Otherwise ask the user briefly which project to target — one short line, no exhaustive list."
96
+ );
97
+ }
98
+ lines.push("");
99
+ lines.push("User feedback:");
100
+ lines.push(userPrompt);
101
+ return lines.join("\n");
102
+ }
package/src/capture.ts CHANGED
@@ -38,6 +38,37 @@ export async function captureScreenshot(): Promise<string> {
38
38
  }
39
39
  }
40
40
 
41
+ /**
42
+ * Capture a screenshot AND return it as base64 + mime type, ready to
43
+ * embed in a `/tasks` payload's `images` array. Used by the converged
44
+ * vibe-feedback flow (FeedbackModal → P2PClient.createFeedbackTask).
45
+ *
46
+ * Returns null when capture isn't possible (peer dep missing / user
47
+ * permission denied / running in a context where view-shot can't
48
+ * grab the screen). Caller should treat null as "send without
49
+ * screenshot" rather than aborting the whole feedback.
50
+ */
51
+ export async function captureScreenshotBase64(): Promise<{
52
+ base64: string;
53
+ mimeType: string;
54
+ } | null> {
55
+ try {
56
+ const ViewShot = require('react-native-view-shot');
57
+ const result = await ViewShot.captureScreen({
58
+ format: 'jpg',
59
+ quality: 0.7,
60
+ result: 'base64',
61
+ });
62
+ if (typeof result === 'string' && result.length > 0) {
63
+ // ViewShot returns a bare base64 string (no `data:` prefix).
64
+ return { base64: result, mimeType: 'image/jpeg' };
65
+ }
66
+ return null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
41
72
  export interface PickedFeedbackFile {
42
73
  path: string;
43
74
  name: string;
@@ -149,3 +149,59 @@ export async function setQuickIconColorPreset(
149
149
  export async function clearQuickIconColorPreset(): Promise<void> {
150
150
  await setQuickIconColorPreset(null);
151
151
  }
152
+
153
+ // ── Preferred coding agent + model (used by the standalone feedback
154
+ // SDK's vibe chat to mirror what Yaver mobile's Tasks tab would send.
155
+ // The agent on the remote DOES read userSettings.primaryRunnerByDevice
156
+ // from Convex, but the standalone SDK has no DeviceContext to push the
157
+ // per-device pick. We persist the user's last choice locally; first
158
+ // run picks whatever's signed-in via getRunnerStatus().)
159
+
160
+ const PREFERRED_RUNNER_KEY = 'yaver_feedback_preferred_runner';
161
+ const PREFERRED_MODEL_KEY = 'yaver_feedback_preferred_model';
162
+
163
+ export async function getPreferredRunner(): Promise<string | null> {
164
+ if (!AsyncStorage) return null;
165
+ try {
166
+ const v = await AsyncStorage.getItem(PREFERRED_RUNNER_KEY);
167
+ return v && v.trim() ? v.trim() : null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ export async function setPreferredRunner(runner: string | null): Promise<void> {
174
+ if (!AsyncStorage) return;
175
+ try {
176
+ if (!runner || !runner.trim()) {
177
+ await AsyncStorage.removeItem(PREFERRED_RUNNER_KEY);
178
+ return;
179
+ }
180
+ await AsyncStorage.setItem(PREFERRED_RUNNER_KEY, runner.trim());
181
+ } catch {
182
+ /* best-effort */
183
+ }
184
+ }
185
+
186
+ export async function getPreferredModel(): Promise<string | null> {
187
+ if (!AsyncStorage) return null;
188
+ try {
189
+ const v = await AsyncStorage.getItem(PREFERRED_MODEL_KEY);
190
+ return v && v.trim() ? v.trim() : null;
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ export async function setPreferredModel(model: string | null): Promise<void> {
197
+ if (!AsyncStorage) return;
198
+ try {
199
+ if (!model || !model.trim()) {
200
+ await AsyncStorage.removeItem(PREFERRED_MODEL_KEY);
201
+ return;
202
+ }
203
+ await AsyncStorage.setItem(PREFERRED_MODEL_KEY, model.trim());
204
+ } catch {
205
+ /* best-effort */
206
+ }
207
+ }
package/src/types.ts CHANGED
@@ -123,6 +123,15 @@ export interface FeedbackConfig {
123
123
  preferredDeviceId?: string;
124
124
  /** How feedback collection is triggered */
125
125
  trigger?: 'shake' | 'floating-button' | 'manual';
126
+ /**
127
+ * App slug used by the in-modal Deploy panel when calling the agent's
128
+ * `/fleet/deploy-options` and `/deploy/ship` endpoints. Should match an
129
+ * `apps[].name` entry in the agent's `yaver.workspace.yaml`. When omitted
130
+ * the panel falls back to the last dot-segment of `bundleId` (e.g.
131
+ * `io.yaver.sfmg` → `sfmg`). Set explicitly when the workspace name
132
+ * differs from the bundleId tail.
133
+ */
134
+ deployAppSlug?: string;
126
135
  /**
127
136
  * Non-default escape hatch for host apps that want the SDK without
128
137
  * shake gesture handling. When enabled:
package/src/upload.ts CHANGED
@@ -18,6 +18,7 @@ export async function uploadFeedback(
18
18
  agentUrl: string,
19
19
  authToken: string,
20
20
  bundle: FeedbackBundle,
21
+ relayPassword: string = '',
21
22
  ): Promise<{ id?: string; reportId?: string; [k: string]: unknown }> {
22
23
  const formData = new FormData();
23
24
 
@@ -54,11 +55,15 @@ export async function uploadFeedback(
54
55
 
55
56
  const url = agentUrl.replace(/\/$/, '') + '/feedback';
56
57
 
58
+ const headers: Record<string, string> = {
59
+ Authorization: `Bearer ${authToken}`,
60
+ };
61
+ if (relayPassword) {
62
+ headers['X-Relay-Password'] = relayPassword;
63
+ }
57
64
  const response = await fetch(url, {
58
65
  method: 'POST',
59
- headers: {
60
- Authorization: `Bearer ${authToken}`,
61
- },
66
+ headers,
62
67
  body: formData,
63
68
  });
64
69