yaver-feedback-react-native 0.8.12 → 0.9.0

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.
@@ -11,6 +11,7 @@ import {
11
11
  TextInput,
12
12
  TouchableOpacity,
13
13
  View,
14
+ useWindowDimensions,
14
15
  } from 'react-native';
15
16
  import { YaverFeedback } from './YaverFeedback';
16
17
  import { FixReport } from './FixReport';
@@ -58,6 +59,15 @@ export interface FloatingButtonProps {
58
59
 
59
60
  const DEFAULT_SIZE = 40;
60
61
  const DEFAULT_COLOR = '#6366f1';
62
+
63
+ // Tablet detection — short-edge dp >= 600 means iPad / 7"+ Android
64
+ // tablet / Z Fold open. The SDK has no app-side responsive context
65
+ // to lean on (it's a guest in third-party apps), so we infer
66
+ // locally and bump the button + panel to tablet sizes.
67
+ const TABLET_SHORT_EDGE = 600;
68
+ function isTabletWindow(width: number, height: number): boolean {
69
+ return Math.min(width, height) >= TABLET_SHORT_EDGE;
70
+ }
61
71
  const DEFAULT_PANEL_BG = '#2d2d2d';
62
72
 
63
73
  /**
@@ -101,7 +111,16 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
101
111
  healthCheckInterval = 5000,
102
112
  panelBackgroundColor,
103
113
  }) => {
104
- const { width: screenWidth } = Dimensions.get('window');
114
+ // Read window size live so the SDK overlay re-pins itself when
115
+ // the host app rotates or splits. The legacy snapshot via
116
+ // Dimensions.get only ran once and parked the button off-screen
117
+ // after orientation changes on iPad.
118
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
119
+ const isTablet = isTabletWindow(screenWidth, screenHeight);
120
+ // Tablets get a larger touch target and a wider panel — phones
121
+ // keep the existing 40 / 280 defaults so guest apps aren't
122
+ // disrupted on small screens.
123
+ const effectiveSize = isTablet ? Math.max(size, 52) : size;
105
124
  const defaultX = initialPosition?.x ?? 10;
106
125
  const defaultY = initialPosition?.y ?? 90;
107
126
 
@@ -535,7 +554,12 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
535
554
  const buttonIcon = icon ?? 'y';
536
555
  const btnBg = isConnected ? color : `${color}88`;
537
556
 
538
- const panelWidth = fullSize ? screenWidth - 24 : 280;
557
+ // Panel sizing tablets get a wider compact panel (420) and a
558
+ // capped full-size panel (max 720 instead of full window) so the
559
+ // overlay doesn't dwarf the host app on a 12.9" iPad.
560
+ const compactPanelWidth = isTablet ? 420 : 280;
561
+ const fullPanelWidth = isTablet ? Math.min(screenWidth - 24, 720) : screenWidth - 24;
562
+ const panelWidth = fullSize ? fullPanelWidth : compactPanelWidth;
539
563
 
540
564
  return (
541
565
  <Animated.View
@@ -730,7 +754,7 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
730
754
  style={[
731
755
  s.button,
732
756
  isTerminal ? s.buttonTerminal : s.buttonMinimal,
733
- { backgroundColor: btnBg, width: size, height: size },
757
+ { backgroundColor: btnBg, width: effectiveSize, height: effectiveSize },
734
758
  !isTerminal && { borderRadius: size / 2 },
735
759
  ]}
736
760
  activeOpacity={0.7}
@@ -102,7 +102,14 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
102
102
  return;
103
103
  }
104
104
  const direct = await probeDeviceReachability(device);
105
- if (!direct.reachable && !device.needsAuth) {
105
+ // Do not hard-block selection just because the LAN /health probe
106
+ // failed. The standalone SDK can still reach a healthy machine via
107
+ // the normal selected-device discovery path (including relay), and
108
+ // the Yaver host path may already be proving the machine works.
109
+ // Only treat the machine as unpickable when BOTH:
110
+ // 1. Convex says it is offline, and
111
+ // 2. the direct probe also failed.
112
+ if (!device.isOnline && !direct.reachable && !device.needsAuth) {
106
113
  setError('Selected machine is not responding. Start `yaver serve` on it and try again.');
107
114
  setReachability((prev) => ({ ...prev, [device.deviceId]: direct }));
108
115
  return;
@@ -130,7 +137,9 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
130
137
  ? '#f59e0b'
131
138
  : effectivelyReachable
132
139
  ? '#22c55e'
133
- : explicitlyOffline || !device.isOnline
140
+ : device.isOnline
141
+ ? '#f59e0b'
142
+ : explicitlyOffline || !device.isOnline
134
143
  ? '#ef4444'
135
144
  : '#22c55e';
136
145
  // Derive a single short status phrase the user can act on.
@@ -145,7 +154,7 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
145
154
  statusLine =
146
155
  'Needs pairing — open the Yaver app to adopt this machine';
147
156
  } else if (explicitlyOffline) {
148
- statusLine = 'Agent not responding on this machine';
157
+ statusLine = 'Online, but direct probe failed — relay / selected-machine path may still work';
149
158
  } else if (device.runnerDown) {
150
159
  statusLine = 'Runner down — restart the coding agent on the Mac';
151
160
  } else {
package/src/P2PClient.ts CHANGED
@@ -3,8 +3,10 @@ import {
3
3
  CapabilitySnapshot,
4
4
  FeedbackBundle,
5
5
  IncidentEvent,
6
+ OpenCodeConfigSummary,
6
7
  OperationState,
7
8
  RunnerBrowserAuthSession,
9
+ RunnerAuthStatusRow,
8
10
  TestSession,
9
11
  VoiceCapability,
10
12
  } from './types';
@@ -31,7 +33,7 @@ export interface ReloadAck {
31
33
  * NativeModules. None of the lookups throw — missing data just means
32
34
  * the agent will fall back to its own dev-server resolution.
33
35
  */
34
- function resolveAppIdentity(opts?: {
36
+ export function resolveAppIdentity(opts?: {
35
37
  projectName?: string;
36
38
  bundleId?: string;
37
39
  projectPath?: string;
@@ -154,6 +156,25 @@ export class P2PClient {
154
156
  this.relayPassword = password;
155
157
  }
156
158
 
159
+ /** Read-only base URL — used by the voice vibe-coding path to probe
160
+ * GET /voice/status before opening the stream. */
161
+ get agentBaseUrl(): string {
162
+ return this.baseUrl;
163
+ }
164
+
165
+ /** WebSocket URL for the agent's voice stream (WS /voice/stream). The
166
+ * voice vibe-coding loop streams mic audio here and receives the
167
+ * transcript + agent task + TTS frames back. */
168
+ voiceStreamUrl(): string {
169
+ return this.baseUrl.replace(/^http/, 'ws') + '/voice/stream';
170
+ }
171
+
172
+ /** Auth headers for the voice WS + status probe — same bearer (and
173
+ * relay password) as every other agent request. */
174
+ voiceAuthHeaders(): Record<string, string> {
175
+ return this.authHeaders();
176
+ }
177
+
157
178
  /** Merge in Authorization + (optional) X-Relay-Password on top of a header block. */
158
179
  private authHeaders(extra: Record<string, string> = {}): Record<string, string> {
159
180
  const h: Record<string, string> = { ...extra };
@@ -222,6 +243,60 @@ export class P2PClient {
222
243
  return data.session as RunnerBrowserAuthSession;
223
244
  }
224
245
 
246
+ async getRunnerAuthStatus(): Promise<RunnerAuthStatusRow[]> {
247
+ const resp = await fetch(`${this.baseUrl}/runner-auth/status`, {
248
+ headers: this.authHeaders(),
249
+ });
250
+ if (!resp.ok) {
251
+ const text = await resp.text().catch(() => '');
252
+ throw new Error(`getRunnerAuthStatus HTTP ${resp.status}: ${text}`);
253
+ }
254
+ const data = await resp.json().catch(() => ({} as Record<string, unknown>));
255
+ return Array.isArray(data.runners) ? (data.runners as RunnerAuthStatusRow[]) : [];
256
+ }
257
+
258
+ async getOpenCodeConfig(): Promise<OpenCodeConfigSummary | null> {
259
+ const resp = await fetch(`${this.baseUrl}/runner/opencode/config`, {
260
+ headers: this.authHeaders(),
261
+ });
262
+ if (!resp.ok) {
263
+ const text = await resp.text().catch(() => '');
264
+ throw new Error(`getOpenCodeConfig HTTP ${resp.status}: ${text}`);
265
+ }
266
+ const data = await resp.json().catch(() => ({} as Record<string, unknown>));
267
+ return (data.config ?? null) as OpenCodeConfigSummary | null;
268
+ }
269
+
270
+ async saveOpenCodeConfig(patch: {
271
+ defaultAgent?: string;
272
+ model?: string;
273
+ smallModel?: string;
274
+ buildModel?: string;
275
+ planModel?: string;
276
+ providers?: Array<{
277
+ id: string;
278
+ name?: string;
279
+ baseUrl?: string;
280
+ apiKey?: string;
281
+ delete?: boolean;
282
+ }>;
283
+ }): Promise<{ ok: boolean; config?: OpenCodeConfigSummary; error?: string }> {
284
+ try {
285
+ const resp = await fetch(`${this.baseUrl}/runner/opencode/config`, {
286
+ method: 'POST',
287
+ headers: { ...this.authHeaders(), 'Content-Type': 'application/json' },
288
+ body: JSON.stringify(patch),
289
+ });
290
+ const data = await resp.json().catch(() => ({} as Record<string, unknown>));
291
+ if (!resp.ok) {
292
+ return { ok: false, error: (data.error as string | undefined) || `HTTP ${resp.status}` };
293
+ }
294
+ return { ok: true, config: data.config as OpenCodeConfigSummary | undefined };
295
+ } catch (err) {
296
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
297
+ }
298
+ }
299
+
225
300
  async capabilitySnapshot(): Promise<CapabilitySnapshot | null> {
226
301
  try {
227
302
  const resp = await fetch(`${this.baseUrl}/capabilities/snapshot`, { headers: this.authHeaders() });
@@ -422,6 +497,10 @@ export class P2PClient {
422
497
  s2sReady: data.s2sReady ?? false,
423
498
  sttProvider: data.sttProvider ?? undefined,
424
499
  sttReady: data.sttReady ?? false,
500
+ ttsProvider: data.ttsProvider ?? undefined,
501
+ ttsReady: data.ttsReady ?? false,
502
+ enabled: data.enabled ?? false,
503
+ defaultProject: data.defaultProject ?? undefined,
425
504
  };
426
505
  }
427
506
 
@@ -807,6 +886,202 @@ export class P2PClient {
807
886
  }
808
887
 
809
888
  /** Internal helper for authenticated GET/POST requests. */
889
+ /**
890
+ * Convergence point for ALL feedback surfaces — Tasks tab, in-Yaver
891
+ * native pane, and this standalone SDK all POST the same shape to
892
+ * `/tasks`. Wraps the user's text with the shared prompt builder
893
+ * (see `_core/buildFeedbackPrompt`) so every surface conditions
894
+ * the AI the same way.
895
+ *
896
+ * Returns the agent's response payload (`taskId`, etc.) so callers
897
+ * can wire `streamTaskOutput()` next for live transcript.
898
+ *
899
+ * Inputs:
900
+ * - userPrompt what the user typed
901
+ * - projectName / path optional Hot-Reload project context
902
+ * - runner / model optional preferred coding agent + model
903
+ * - screenshotBase64 optional JPEG base64 (no `data:` prefix)
904
+ * - imageMimeType defaults to "image/jpeg"
905
+ */
906
+ async createFeedbackTask(input: {
907
+ userPrompt: string;
908
+ projectName?: string;
909
+ projectPath?: string;
910
+ runner?: string;
911
+ model?: string;
912
+ screenshotBase64?: string;
913
+ imageMimeType?: string;
914
+ }): Promise<{ taskId: string; raw?: unknown }> {
915
+ const { buildFeedbackPrompt } = await import('./_core/buildFeedbackPrompt');
916
+ const hasScreenshot = !!(input.screenshotBase64 && input.screenshotBase64.length > 0);
917
+ const description = buildFeedbackPrompt({
918
+ userPrompt: input.userPrompt,
919
+ projectName: input.projectName,
920
+ projectPath: input.projectPath,
921
+ hasScreenshot,
922
+ });
923
+ const images: Array<{ base64: string; mimeType: string; filename: string }> = [];
924
+ if (hasScreenshot && input.screenshotBase64) {
925
+ images.push({
926
+ base64: input.screenshotBase64,
927
+ mimeType: input.imageMimeType ?? 'image/jpeg',
928
+ filename: `yaver-feedback-${Math.floor(Date.now() / 1000)}.jpg`,
929
+ });
930
+ }
931
+ const body: Record<string, unknown> = {
932
+ title: input.userPrompt.slice(0, 80),
933
+ description,
934
+ userPrompt: input.userPrompt,
935
+ source: 'mobile-feedback',
936
+ images,
937
+ };
938
+ if (input.projectPath && input.projectPath.trim()) body.workDir = input.projectPath.trim();
939
+ if (input.projectName && input.projectName.trim()) body.projectName = input.projectName.trim();
940
+ if (input.runner && input.runner.trim()) body.runner = input.runner.trim();
941
+ if (input.model && input.model.trim()) body.model = input.model.trim();
942
+
943
+ const resp = await fetch(`${this.baseUrl}/tasks`, {
944
+ method: 'POST',
945
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
946
+ body: JSON.stringify(body),
947
+ });
948
+ if (!resp.ok) {
949
+ const text = await resp.text().catch(() => '');
950
+ throw new Error(`createFeedbackTask HTTP ${resp.status}: ${text}`);
951
+ }
952
+ const json = (await resp.json().catch(() => ({}))) as { taskId?: string };
953
+ if (!json.taskId) {
954
+ throw new Error('createFeedbackTask: agent did not return taskId');
955
+ }
956
+ return { taskId: json.taskId, raw: json };
957
+ }
958
+
959
+ /**
960
+ * Subscribe to a task's live stdout/stderr stream. Returns an abort
961
+ * function — call it to detach. The agent emits NDJSON lines on
962
+ * `/tasks/{id}/output`; we surface each line via `onLine`.
963
+ *
964
+ * `onComplete` fires when the agent reports the task entered a
965
+ * terminal status (completed / failed / stopped). After that the
966
+ * caller should stop calling abort().
967
+ *
968
+ * Robust to fetch streaming on Hermes (streams Body via Response.
969
+ * body.getReader on platforms that support it; falls back to
970
+ * polling `/tasks/{id}` every 750 ms if streaming isn't available).
971
+ */
972
+ streamTaskOutput(
973
+ taskId: string,
974
+ onLine: (line: string) => void,
975
+ onComplete: (status: string) => void,
976
+ ): () => void {
977
+ const ctrl = new AbortController();
978
+ let closed = false;
979
+ const close = () => {
980
+ if (closed) return;
981
+ closed = true;
982
+ try { ctrl.abort(); } catch { /* ignore */ }
983
+ };
984
+
985
+ (async () => {
986
+ try {
987
+ const resp = await fetch(`${this.baseUrl}/tasks/${encodeURIComponent(taskId)}/output`, {
988
+ method: 'GET',
989
+ headers: this.authHeaders({ Accept: 'text/event-stream' }),
990
+ signal: ctrl.signal,
991
+ });
992
+ if (!resp.ok) {
993
+ throw new Error(`streamTaskOutput HTTP ${resp.status}`);
994
+ }
995
+ // RN Hermes: Response.body may be undefined. Fall back to
996
+ // polling final state.
997
+ const body = (resp as unknown as { body?: ReadableStream<Uint8Array> }).body;
998
+ if (!body || typeof body.getReader !== 'function') {
999
+ await pollTaskUntilDone(this, taskId, onLine, onComplete, () => closed);
1000
+ return;
1001
+ }
1002
+ const reader = body.getReader();
1003
+ const decoder = new TextDecoder();
1004
+ let buf = '';
1005
+ while (!closed) {
1006
+ const { value, done } = await reader.read();
1007
+ if (done) break;
1008
+ buf += decoder.decode(value, { stream: true });
1009
+ // SSE frames are separated by \n\n; payloads are `data: <json>\n`.
1010
+ let idx = buf.indexOf('\n\n');
1011
+ while (idx >= 0) {
1012
+ const frame = buf.slice(0, idx);
1013
+ buf = buf.slice(idx + 2);
1014
+ for (const line of frame.split('\n')) {
1015
+ if (line.startsWith('data:')) {
1016
+ const payload = line.slice(5).trim();
1017
+ if (payload) onLine(payload);
1018
+ }
1019
+ }
1020
+ idx = buf.indexOf('\n\n');
1021
+ }
1022
+ }
1023
+ // Stream closed cleanly — query final status.
1024
+ try {
1025
+ const final = await fetch(
1026
+ `${this.baseUrl}/tasks/${encodeURIComponent(taskId)}`,
1027
+ { headers: this.authHeaders() },
1028
+ );
1029
+ const j = (await final.json().catch(() => ({}))) as { status?: string };
1030
+ onComplete(j.status ?? 'completed');
1031
+ } catch {
1032
+ onComplete('completed');
1033
+ }
1034
+ } catch (e) {
1035
+ if (!closed) {
1036
+ // Surface the error via onLine so the UI shows it inline,
1037
+ // then mark complete so the caller stops waiting.
1038
+ onLine(`__error__: ${e instanceof Error ? e.message : String(e)}`);
1039
+ onComplete('failed');
1040
+ }
1041
+ }
1042
+ })();
1043
+ return close;
1044
+ }
1045
+
1046
+ /**
1047
+ * Send a follow-up message into an existing task — multi-turn vibe
1048
+ * chat. The agent's `/tasks/{id}/resume` accepts the same shape as
1049
+ * `/tasks` (description / userPrompt / images), and the existing
1050
+ * task picks back up with the same runner + project context.
1051
+ */
1052
+ async resumeTask(input: {
1053
+ taskId: string;
1054
+ userPrompt: string;
1055
+ screenshotBase64?: string;
1056
+ imageMimeType?: string;
1057
+ }): Promise<void> {
1058
+ const images: Array<{ base64: string; mimeType: string; filename: string }> = [];
1059
+ if (input.screenshotBase64 && input.screenshotBase64.length > 0) {
1060
+ images.push({
1061
+ base64: input.screenshotBase64,
1062
+ mimeType: input.imageMimeType ?? 'image/jpeg',
1063
+ filename: `yaver-feedback-followup-${Math.floor(Date.now() / 1000)}.jpg`,
1064
+ });
1065
+ }
1066
+ const resp = await fetch(
1067
+ `${this.baseUrl}/tasks/${encodeURIComponent(input.taskId)}/resume`,
1068
+ {
1069
+ method: 'POST',
1070
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
1071
+ body: JSON.stringify({
1072
+ description: input.userPrompt,
1073
+ userPrompt: input.userPrompt,
1074
+ source: 'mobile-feedback',
1075
+ images,
1076
+ }),
1077
+ },
1078
+ );
1079
+ if (!resp.ok) {
1080
+ const text = await resp.text().catch(() => '');
1081
+ throw new Error(`resumeTask HTTP ${resp.status}: ${text}`);
1082
+ }
1083
+ }
1084
+
810
1085
  private async request(method: string, path: string): Promise<Response> {
811
1086
  const response = await fetch(`${this.baseUrl}${path}`, {
812
1087
  method,
@@ -823,3 +1098,53 @@ export class P2PClient {
823
1098
  return response;
824
1099
  }
825
1100
  }
1101
+
1102
+ /**
1103
+ * Fallback path used by streamTaskOutput when the platform's fetch
1104
+ * returns a Response with no streaming body (older Hermes builds).
1105
+ * Polls `/tasks/{id}` every 750 ms; surfaces newly-appended output
1106
+ * lines via `onLine`, fires `onComplete` when status is terminal.
1107
+ */
1108
+ async function pollTaskUntilDone(
1109
+ client: P2PClient,
1110
+ taskId: string,
1111
+ onLine: (line: string) => void,
1112
+ onComplete: (status: string) => void,
1113
+ isClosed: () => boolean,
1114
+ ): Promise<void> {
1115
+ // Use bracket access to read the private baseUrl/authHeaders without
1116
+ // making them public — confined to this file's scope.
1117
+ const c = client as unknown as {
1118
+ baseUrl: string;
1119
+ authHeaders: (extra?: Record<string, string>) => Record<string, string>;
1120
+ };
1121
+ let lastLen = 0;
1122
+ for (;;) {
1123
+ if (isClosed()) return;
1124
+ try {
1125
+ const r = await fetch(`${c.baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
1126
+ headers: c.authHeaders(),
1127
+ });
1128
+ const j = (await r.json().catch(() => ({}))) as {
1129
+ status?: string;
1130
+ output?: string[] | string;
1131
+ };
1132
+ const all = Array.isArray(j.output) ? j.output : (j.output ? [j.output] : []);
1133
+ const flat = all.join('\n');
1134
+ if (flat.length > lastLen) {
1135
+ const fresh = flat.slice(lastLen);
1136
+ lastLen = flat.length;
1137
+ for (const ln of fresh.split('\n')) {
1138
+ if (ln.length > 0) onLine(ln);
1139
+ }
1140
+ }
1141
+ if (j.status && ['completed', 'failed', 'stopped'].includes(j.status)) {
1142
+ onComplete(j.status);
1143
+ return;
1144
+ }
1145
+ } catch {
1146
+ // Transient — keep polling.
1147
+ }
1148
+ await new Promise((res) => setTimeout(res, 750));
1149
+ }
1150
+ }