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.
- package/app.plugin.js +13 -0
- package/dist/BlackBox.d.ts +1 -0
- package/dist/BlackBox.js +29 -14
- package/dist/DeployPanel.d.ts +7 -0
- package/dist/DeployPanel.js +354 -0
- package/dist/FeedbackModal.js +94 -6
- package/dist/FloatingButton.js +25 -3
- package/dist/P2PClient.d.ts +71 -0
- package/dist/P2PClient.js +254 -0
- package/dist/VibeChatScreen.d.ts +20 -0
- package/dist/VibeChatScreen.js +328 -0
- package/dist/YaverFeedback.d.ts +7 -0
- package/dist/YaverFeedback.js +10 -1
- package/dist/_core/buildFeedbackPrompt.d.ts +13 -0
- package/dist/_core/buildFeedbackPrompt.js +77 -0
- package/dist/capture.d.ts +14 -0
- package/dist/capture.js +29 -0
- package/dist/preferences.d.ts +4 -0
- package/dist/preferences.js +62 -0
- package/dist/types.d.ts +9 -0
- package/dist/upload.d.ts +1 -1
- package/dist/upload.js +8 -4
- package/package.json +14 -2
- package/src/BlackBox.ts +29 -14
- package/src/DeployPanel.tsx +403 -0
- package/src/FeedbackModal.tsx +126 -5
- package/src/FloatingButton.tsx +27 -3
- package/src/P2PClient.ts +247 -1
- package/src/VibeChatScreen.tsx +362 -0
- package/src/YaverFeedback.ts +11 -1
- package/src/_core/buildFeedbackPrompt.ts +102 -0
- package/src/capture.ts +31 -0
- package/src/preferences.ts +56 -0
- package/src/types.ts +9 -0
- package/src/upload.ts +8 -3
package/src/FeedbackModal.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
Text,
|
|
13
13
|
TextInput,
|
|
14
14
|
View,
|
|
15
|
+
useWindowDimensions,
|
|
15
16
|
} from 'react-native';
|
|
16
17
|
import { YaverFeedback } from './YaverFeedback';
|
|
17
18
|
import {
|
|
@@ -27,6 +28,8 @@ import { uploadFeedback } from './upload';
|
|
|
27
28
|
import { DeviceInfo, FeedbackBundle } from './types';
|
|
28
29
|
import { AuthOverlay } from './AuthOverlay';
|
|
29
30
|
import { QuickActionIcon } from './QuickActionIcon';
|
|
31
|
+
import { VibeChatScreen } from './VibeChatScreen';
|
|
32
|
+
import { DeployPanel } from './DeployPanel';
|
|
30
33
|
import { listReachableDevices, RemoteDevice } from './auth';
|
|
31
34
|
import {
|
|
32
35
|
QUICK_ICON_COLOR_PRESETS,
|
|
@@ -62,6 +65,11 @@ type MachineCardState = {
|
|
|
62
65
|
};
|
|
63
66
|
|
|
64
67
|
export const FeedbackModal: React.FC = () => {
|
|
68
|
+
const { width: winW, height: winH } = useWindowDimensions();
|
|
69
|
+
const isTablet = Math.min(winW, winH) >= 600;
|
|
70
|
+
// Tablet color/icon picker fans out to 5/6 cols — 31% (3-col)
|
|
71
|
+
// looks empty on a 1024pt iPad. Mobile keeps 3-col.
|
|
72
|
+
const iconOptionWidthOverride = isTablet ? '18%' : undefined;
|
|
65
73
|
const [visible, setVisible] = useState(false);
|
|
66
74
|
const [action, setAction] = useState<ActionState>('idle');
|
|
67
75
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -79,6 +87,7 @@ export const FeedbackModal: React.FC = () => {
|
|
|
79
87
|
// "pick something for me" prompt (which in 0.7.13 pointed Claude at
|
|
80
88
|
// the wrong project because the matcher grepped the prompt itself).
|
|
81
89
|
const [showVibeInput, setShowVibeInput] = useState(false);
|
|
90
|
+
const [showDeploy, setShowDeploy] = useState(false);
|
|
82
91
|
const [vibePrompt, setVibePrompt] = useState('');
|
|
83
92
|
const [lastVibeTaskId, setLastVibeTaskId] = useState<string | null>(null);
|
|
84
93
|
const [quickIconColorPreset, setQuickIconColorPreset] =
|
|
@@ -415,6 +424,7 @@ export const FeedbackModal: React.FC = () => {
|
|
|
415
424
|
config.agentUrl,
|
|
416
425
|
config.authToken ?? '',
|
|
417
426
|
bundle,
|
|
427
|
+
YaverFeedback.getRelayPassword(),
|
|
418
428
|
);
|
|
419
429
|
// The agent returns the new report id as `id` (see
|
|
420
430
|
// feedback_http.go::ReceiveFeedback). Trigger the fix loop if we got
|
|
@@ -534,6 +544,17 @@ export const FeedbackModal: React.FC = () => {
|
|
|
534
544
|
}
|
|
535
545
|
}, [showVibeInput, vibePrompt]);
|
|
536
546
|
|
|
547
|
+
// Hold the active vibe-chat session — set when handleVibingSubmit
|
|
548
|
+
// returns a fresh taskId. Renders <VibeChatScreen> which streams the
|
|
549
|
+
// SSE transcript, supports multi-turn follow-ups via /tasks/{id}/
|
|
550
|
+
// resume, and exposes a Reload button. Mirrors the in-Yaver native
|
|
551
|
+
// pane's transcript-mode behaviour, just rendered in RN here.
|
|
552
|
+
const [activeVibe, setActiveVibe] = useState<{
|
|
553
|
+
taskId: string;
|
|
554
|
+
initialPrompt: string;
|
|
555
|
+
} | null>(null);
|
|
556
|
+
const [includeScreenshot, setIncludeScreenshot] = useState<boolean>(true);
|
|
557
|
+
|
|
537
558
|
const handleVibingSubmit = useCallback(async () => {
|
|
538
559
|
const client = YaverFeedback.getP2PClient();
|
|
539
560
|
if (!client) {
|
|
@@ -553,13 +574,47 @@ export const FeedbackModal: React.FC = () => {
|
|
|
553
574
|
.join('\n')
|
|
554
575
|
: '';
|
|
555
576
|
const userPrompt = vibePrompt.trim();
|
|
556
|
-
const
|
|
577
|
+
const promptText = userPrompt
|
|
557
578
|
? userPrompt + errNote
|
|
558
579
|
: 'Pick the next small improvement or fix for this app based on recent activity and the current screen.' +
|
|
559
580
|
errNote;
|
|
560
|
-
|
|
581
|
+
|
|
582
|
+
// Optional screenshot — captured from the host app's window.
|
|
583
|
+
// captureScreenshotBase64 returns null when react-native-view-
|
|
584
|
+
// shot isn't installed; we skip the screenshot rather than
|
|
585
|
+
// abort the whole feedback in that case.
|
|
586
|
+
let screenshotBase64: string | undefined;
|
|
587
|
+
if (includeScreenshot) {
|
|
588
|
+
const cap = await import('./capture');
|
|
589
|
+
const captured = await cap.captureScreenshotBase64();
|
|
590
|
+
if (captured?.base64) {
|
|
591
|
+
screenshotBase64 = captured.base64;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Resolve project context the same way reloadApp / vibing did.
|
|
596
|
+
const { resolveAppIdentity } = await import('./P2PClient');
|
|
597
|
+
const identity = resolveAppIdentity();
|
|
598
|
+
|
|
599
|
+
// Pull the user's preferred runner / model from local prefs.
|
|
600
|
+
// Both are optional — the agent falls back to whatever runner
|
|
601
|
+
// is signed in if neither is provided.
|
|
602
|
+
const prefs = await import('./preferences');
|
|
603
|
+
const preferredRunner = (await prefs.getPreferredRunner?.()) ?? null;
|
|
604
|
+
const preferredModel = (await prefs.getPreferredModel?.()) ?? null;
|
|
605
|
+
|
|
606
|
+
const result = await client.createFeedbackTask({
|
|
607
|
+
userPrompt: promptText,
|
|
608
|
+
projectName: identity.projectName,
|
|
609
|
+
projectPath: identity.projectPath,
|
|
610
|
+
runner: preferredRunner ?? undefined,
|
|
611
|
+
model: preferredModel ?? undefined,
|
|
612
|
+
screenshotBase64,
|
|
613
|
+
});
|
|
561
614
|
setLastVibeTaskId(result.taskId);
|
|
562
|
-
|
|
615
|
+
// Hand off to VibeChatScreen — it streams the SSE transcript,
|
|
616
|
+
// accepts follow-ups, and surfaces a Reload button.
|
|
617
|
+
setActiveVibe({ taskId: result.taskId, initialPrompt: promptText });
|
|
563
618
|
setVibePrompt('');
|
|
564
619
|
setShowVibeInput(false);
|
|
565
620
|
} catch (err: unknown) {
|
|
@@ -567,7 +622,7 @@ export const FeedbackModal: React.FC = () => {
|
|
|
567
622
|
} finally {
|
|
568
623
|
if (mountedRef.current) setAction('idle');
|
|
569
624
|
}
|
|
570
|
-
}, [vibePrompt]);
|
|
625
|
+
}, [vibePrompt, includeScreenshot]);
|
|
571
626
|
|
|
572
627
|
/*
|
|
573
628
|
const handleScreenRecording = useCallback(async () => {
|
|
@@ -577,6 +632,40 @@ export const FeedbackModal: React.FC = () => {
|
|
|
577
632
|
|
|
578
633
|
const busy = action !== 'idle';
|
|
579
634
|
|
|
635
|
+
// Once the user fires off a vibe task, swap the entire modal body
|
|
636
|
+
// for the live chat screen. The chat manages its own SSE
|
|
637
|
+
// subscription, multi-turn follow-ups, and Reload button. Closing
|
|
638
|
+
// the chat returns to idle and clears the active vibe.
|
|
639
|
+
if (visible && activeVibe) {
|
|
640
|
+
const client = YaverFeedback.getP2PClient();
|
|
641
|
+
return (
|
|
642
|
+
<>
|
|
643
|
+
<AuthOverlay />
|
|
644
|
+
<QuickActionIcon />
|
|
645
|
+
<Modal
|
|
646
|
+
visible={visible}
|
|
647
|
+
animationType="slide"
|
|
648
|
+
transparent
|
|
649
|
+
onRequestClose={() => setActiveVibe(null)}
|
|
650
|
+
>
|
|
651
|
+
{client ? (
|
|
652
|
+
<VibeChatScreen
|
|
653
|
+
client={client}
|
|
654
|
+
initialTaskId={activeVibe.taskId}
|
|
655
|
+
initialUserPrompt={activeVibe.initialPrompt}
|
|
656
|
+
onClose={() => setActiveVibe(null)}
|
|
657
|
+
onReload={async () => {
|
|
658
|
+
const c = YaverFeedback.getP2PClient();
|
|
659
|
+
if (!c) throw new Error('Not connected');
|
|
660
|
+
await c.reloadApp();
|
|
661
|
+
}}
|
|
662
|
+
/>
|
|
663
|
+
) : null}
|
|
664
|
+
</Modal>
|
|
665
|
+
</>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
580
669
|
return (
|
|
581
670
|
<>
|
|
582
671
|
<AuthOverlay />
|
|
@@ -596,7 +685,21 @@ export const FeedbackModal: React.FC = () => {
|
|
|
596
685
|
pointerEvents="box-none"
|
|
597
686
|
>
|
|
598
687
|
<Pressable
|
|
599
|
-
style
|
|
688
|
+
// Tablet: cap modal width and center as a card-style
|
|
689
|
+
// sheet rather than a phone bottom sheet that stretches
|
|
690
|
+
// across a 12.9" iPad. Phone behaviour unchanged.
|
|
691
|
+
style={[
|
|
692
|
+
styles.modal,
|
|
693
|
+
isTablet
|
|
694
|
+
? {
|
|
695
|
+
width: '100%',
|
|
696
|
+
maxWidth: 640,
|
|
697
|
+
alignSelf: 'center',
|
|
698
|
+
borderTopLeftRadius: 22,
|
|
699
|
+
borderTopRightRadius: 22,
|
|
700
|
+
}
|
|
701
|
+
: null,
|
|
702
|
+
]}
|
|
600
703
|
onPress={(e) => {
|
|
601
704
|
e.stopPropagation();
|
|
602
705
|
Keyboard.dismiss();
|
|
@@ -702,6 +805,7 @@ export const FeedbackModal: React.FC = () => {
|
|
|
702
805
|
}}
|
|
703
806
|
style={[
|
|
704
807
|
styles.iconOption,
|
|
808
|
+
iconOptionWidthOverride ? { width: iconOptionWidthOverride } : null,
|
|
705
809
|
selected && styles.iconOptionSelected,
|
|
706
810
|
]}
|
|
707
811
|
>
|
|
@@ -810,6 +914,23 @@ export const FeedbackModal: React.FC = () => {
|
|
|
810
914
|
busy={action === 'capturing'}
|
|
811
915
|
/>
|
|
812
916
|
|
|
917
|
+
{/* Deploy — opens an inline panel that talks to
|
|
918
|
+
/fleet/deploy-options on the agent and lets the user
|
|
919
|
+
pick TestFlight / Play / Both, then a machine to run
|
|
920
|
+
it on. Capabilities (e.g. "Linux can't TestFlight")
|
|
921
|
+
come from the agent's doctor probes — no client-side
|
|
922
|
+
platform smarts here. */}
|
|
923
|
+
{!showDeploy ? (
|
|
924
|
+
<ActionRow
|
|
925
|
+
label="Deploy"
|
|
926
|
+
tint="#7f8cf7"
|
|
927
|
+
onPress={() => setShowDeploy(true)}
|
|
928
|
+
disabled={busy}
|
|
929
|
+
/>
|
|
930
|
+
) : (
|
|
931
|
+
<DeployPanel onClose={() => setShowDeploy(false)} />
|
|
932
|
+
)}
|
|
933
|
+
|
|
813
934
|
{/* Remote sign-in buttons — trigger codex/claude device-auth
|
|
814
935
|
on the selected agent without leaving the app. Opens a
|
|
815
936
|
small native modal showing the verification URL + 8-char
|
package/src/FloatingButton.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
757
|
+
{ backgroundColor: btnBg, width: effectiveSize, height: effectiveSize },
|
|
734
758
|
!isTerminal && { borderRadius: size / 2 },
|
|
735
759
|
]}
|
|
736
760
|
activeOpacity={0.7}
|
package/src/P2PClient.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface ReloadAck {
|
|
|
31
31
|
* NativeModules. None of the lookups throw — missing data just means
|
|
32
32
|
* the agent will fall back to its own dev-server resolution.
|
|
33
33
|
*/
|
|
34
|
-
function resolveAppIdentity(opts?: {
|
|
34
|
+
export function resolveAppIdentity(opts?: {
|
|
35
35
|
projectName?: string;
|
|
36
36
|
bundleId?: string;
|
|
37
37
|
projectPath?: string;
|
|
@@ -807,6 +807,202 @@ export class P2PClient {
|
|
|
807
807
|
}
|
|
808
808
|
|
|
809
809
|
/** Internal helper for authenticated GET/POST requests. */
|
|
810
|
+
/**
|
|
811
|
+
* Convergence point for ALL feedback surfaces — Tasks tab, in-Yaver
|
|
812
|
+
* native pane, and this standalone SDK all POST the same shape to
|
|
813
|
+
* `/tasks`. Wraps the user's text with the shared prompt builder
|
|
814
|
+
* (see `_core/buildFeedbackPrompt`) so every surface conditions
|
|
815
|
+
* the AI the same way.
|
|
816
|
+
*
|
|
817
|
+
* Returns the agent's response payload (`taskId`, etc.) so callers
|
|
818
|
+
* can wire `streamTaskOutput()` next for live transcript.
|
|
819
|
+
*
|
|
820
|
+
* Inputs:
|
|
821
|
+
* - userPrompt what the user typed
|
|
822
|
+
* - projectName / path optional Hot-Reload project context
|
|
823
|
+
* - runner / model optional preferred coding agent + model
|
|
824
|
+
* - screenshotBase64 optional JPEG base64 (no `data:` prefix)
|
|
825
|
+
* - imageMimeType defaults to "image/jpeg"
|
|
826
|
+
*/
|
|
827
|
+
async createFeedbackTask(input: {
|
|
828
|
+
userPrompt: string;
|
|
829
|
+
projectName?: string;
|
|
830
|
+
projectPath?: string;
|
|
831
|
+
runner?: string;
|
|
832
|
+
model?: string;
|
|
833
|
+
screenshotBase64?: string;
|
|
834
|
+
imageMimeType?: string;
|
|
835
|
+
}): Promise<{ taskId: string; raw?: unknown }> {
|
|
836
|
+
const { buildFeedbackPrompt } = await import('./_core/buildFeedbackPrompt');
|
|
837
|
+
const hasScreenshot = !!(input.screenshotBase64 && input.screenshotBase64.length > 0);
|
|
838
|
+
const description = buildFeedbackPrompt({
|
|
839
|
+
userPrompt: input.userPrompt,
|
|
840
|
+
projectName: input.projectName,
|
|
841
|
+
projectPath: input.projectPath,
|
|
842
|
+
hasScreenshot,
|
|
843
|
+
});
|
|
844
|
+
const images: Array<{ base64: string; mimeType: string; filename: string }> = [];
|
|
845
|
+
if (hasScreenshot && input.screenshotBase64) {
|
|
846
|
+
images.push({
|
|
847
|
+
base64: input.screenshotBase64,
|
|
848
|
+
mimeType: input.imageMimeType ?? 'image/jpeg',
|
|
849
|
+
filename: `yaver-feedback-${Math.floor(Date.now() / 1000)}.jpg`,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
const body: Record<string, unknown> = {
|
|
853
|
+
title: input.userPrompt.slice(0, 80),
|
|
854
|
+
description,
|
|
855
|
+
userPrompt: input.userPrompt,
|
|
856
|
+
source: 'mobile-feedback',
|
|
857
|
+
images,
|
|
858
|
+
};
|
|
859
|
+
if (input.projectPath && input.projectPath.trim()) body.workDir = input.projectPath.trim();
|
|
860
|
+
if (input.projectName && input.projectName.trim()) body.projectName = input.projectName.trim();
|
|
861
|
+
if (input.runner && input.runner.trim()) body.runner = input.runner.trim();
|
|
862
|
+
if (input.model && input.model.trim()) body.model = input.model.trim();
|
|
863
|
+
|
|
864
|
+
const resp = await fetch(`${this.baseUrl}/tasks`, {
|
|
865
|
+
method: 'POST',
|
|
866
|
+
headers: this.authHeaders({ 'Content-Type': 'application/json' }),
|
|
867
|
+
body: JSON.stringify(body),
|
|
868
|
+
});
|
|
869
|
+
if (!resp.ok) {
|
|
870
|
+
const text = await resp.text().catch(() => '');
|
|
871
|
+
throw new Error(`createFeedbackTask HTTP ${resp.status}: ${text}`);
|
|
872
|
+
}
|
|
873
|
+
const json = (await resp.json().catch(() => ({}))) as { taskId?: string };
|
|
874
|
+
if (!json.taskId) {
|
|
875
|
+
throw new Error('createFeedbackTask: agent did not return taskId');
|
|
876
|
+
}
|
|
877
|
+
return { taskId: json.taskId, raw: json };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Subscribe to a task's live stdout/stderr stream. Returns an abort
|
|
882
|
+
* function — call it to detach. The agent emits NDJSON lines on
|
|
883
|
+
* `/tasks/{id}/output`; we surface each line via `onLine`.
|
|
884
|
+
*
|
|
885
|
+
* `onComplete` fires when the agent reports the task entered a
|
|
886
|
+
* terminal status (completed / failed / stopped). After that the
|
|
887
|
+
* caller should stop calling abort().
|
|
888
|
+
*
|
|
889
|
+
* Robust to fetch streaming on Hermes (streams Body via Response.
|
|
890
|
+
* body.getReader on platforms that support it; falls back to
|
|
891
|
+
* polling `/tasks/{id}` every 750 ms if streaming isn't available).
|
|
892
|
+
*/
|
|
893
|
+
streamTaskOutput(
|
|
894
|
+
taskId: string,
|
|
895
|
+
onLine: (line: string) => void,
|
|
896
|
+
onComplete: (status: string) => void,
|
|
897
|
+
): () => void {
|
|
898
|
+
const ctrl = new AbortController();
|
|
899
|
+
let closed = false;
|
|
900
|
+
const close = () => {
|
|
901
|
+
if (closed) return;
|
|
902
|
+
closed = true;
|
|
903
|
+
try { ctrl.abort(); } catch { /* ignore */ }
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
(async () => {
|
|
907
|
+
try {
|
|
908
|
+
const resp = await fetch(`${this.baseUrl}/tasks/${encodeURIComponent(taskId)}/output`, {
|
|
909
|
+
method: 'GET',
|
|
910
|
+
headers: this.authHeaders({ Accept: 'text/event-stream' }),
|
|
911
|
+
signal: ctrl.signal,
|
|
912
|
+
});
|
|
913
|
+
if (!resp.ok) {
|
|
914
|
+
throw new Error(`streamTaskOutput HTTP ${resp.status}`);
|
|
915
|
+
}
|
|
916
|
+
// RN Hermes: Response.body may be undefined. Fall back to
|
|
917
|
+
// polling final state.
|
|
918
|
+
const body = (resp as unknown as { body?: ReadableStream<Uint8Array> }).body;
|
|
919
|
+
if (!body || typeof body.getReader !== 'function') {
|
|
920
|
+
await pollTaskUntilDone(this, taskId, onLine, onComplete, () => closed);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const reader = body.getReader();
|
|
924
|
+
const decoder = new TextDecoder();
|
|
925
|
+
let buf = '';
|
|
926
|
+
while (!closed) {
|
|
927
|
+
const { value, done } = await reader.read();
|
|
928
|
+
if (done) break;
|
|
929
|
+
buf += decoder.decode(value, { stream: true });
|
|
930
|
+
// SSE frames are separated by \n\n; payloads are `data: <json>\n`.
|
|
931
|
+
let idx = buf.indexOf('\n\n');
|
|
932
|
+
while (idx >= 0) {
|
|
933
|
+
const frame = buf.slice(0, idx);
|
|
934
|
+
buf = buf.slice(idx + 2);
|
|
935
|
+
for (const line of frame.split('\n')) {
|
|
936
|
+
if (line.startsWith('data:')) {
|
|
937
|
+
const payload = line.slice(5).trim();
|
|
938
|
+
if (payload) onLine(payload);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
idx = buf.indexOf('\n\n');
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
// Stream closed cleanly — query final status.
|
|
945
|
+
try {
|
|
946
|
+
const final = await fetch(
|
|
947
|
+
`${this.baseUrl}/tasks/${encodeURIComponent(taskId)}`,
|
|
948
|
+
{ headers: this.authHeaders() },
|
|
949
|
+
);
|
|
950
|
+
const j = (await final.json().catch(() => ({}))) as { status?: string };
|
|
951
|
+
onComplete(j.status ?? 'completed');
|
|
952
|
+
} catch {
|
|
953
|
+
onComplete('completed');
|
|
954
|
+
}
|
|
955
|
+
} catch (e) {
|
|
956
|
+
if (!closed) {
|
|
957
|
+
// Surface the error via onLine so the UI shows it inline,
|
|
958
|
+
// then mark complete so the caller stops waiting.
|
|
959
|
+
onLine(`__error__: ${e instanceof Error ? e.message : String(e)}`);
|
|
960
|
+
onComplete('failed');
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
})();
|
|
964
|
+
return close;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Send a follow-up message into an existing task — multi-turn vibe
|
|
969
|
+
* chat. The agent's `/tasks/{id}/resume` accepts the same shape as
|
|
970
|
+
* `/tasks` (description / userPrompt / images), and the existing
|
|
971
|
+
* task picks back up with the same runner + project context.
|
|
972
|
+
*/
|
|
973
|
+
async resumeTask(input: {
|
|
974
|
+
taskId: string;
|
|
975
|
+
userPrompt: string;
|
|
976
|
+
screenshotBase64?: string;
|
|
977
|
+
imageMimeType?: string;
|
|
978
|
+
}): Promise<void> {
|
|
979
|
+
const images: Array<{ base64: string; mimeType: string; filename: string }> = [];
|
|
980
|
+
if (input.screenshotBase64 && input.screenshotBase64.length > 0) {
|
|
981
|
+
images.push({
|
|
982
|
+
base64: input.screenshotBase64,
|
|
983
|
+
mimeType: input.imageMimeType ?? 'image/jpeg',
|
|
984
|
+
filename: `yaver-feedback-followup-${Math.floor(Date.now() / 1000)}.jpg`,
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
const resp = await fetch(
|
|
988
|
+
`${this.baseUrl}/tasks/${encodeURIComponent(input.taskId)}/resume`,
|
|
989
|
+
{
|
|
990
|
+
method: 'POST',
|
|
991
|
+
headers: this.authHeaders({ 'Content-Type': 'application/json' }),
|
|
992
|
+
body: JSON.stringify({
|
|
993
|
+
description: input.userPrompt,
|
|
994
|
+
userPrompt: input.userPrompt,
|
|
995
|
+
source: 'mobile-feedback',
|
|
996
|
+
images,
|
|
997
|
+
}),
|
|
998
|
+
},
|
|
999
|
+
);
|
|
1000
|
+
if (!resp.ok) {
|
|
1001
|
+
const text = await resp.text().catch(() => '');
|
|
1002
|
+
throw new Error(`resumeTask HTTP ${resp.status}: ${text}`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
810
1006
|
private async request(method: string, path: string): Promise<Response> {
|
|
811
1007
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
812
1008
|
method,
|
|
@@ -823,3 +1019,53 @@ export class P2PClient {
|
|
|
823
1019
|
return response;
|
|
824
1020
|
}
|
|
825
1021
|
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Fallback path used by streamTaskOutput when the platform's fetch
|
|
1025
|
+
* returns a Response with no streaming body (older Hermes builds).
|
|
1026
|
+
* Polls `/tasks/{id}` every 750 ms; surfaces newly-appended output
|
|
1027
|
+
* lines via `onLine`, fires `onComplete` when status is terminal.
|
|
1028
|
+
*/
|
|
1029
|
+
async function pollTaskUntilDone(
|
|
1030
|
+
client: P2PClient,
|
|
1031
|
+
taskId: string,
|
|
1032
|
+
onLine: (line: string) => void,
|
|
1033
|
+
onComplete: (status: string) => void,
|
|
1034
|
+
isClosed: () => boolean,
|
|
1035
|
+
): Promise<void> {
|
|
1036
|
+
// Use bracket access to read the private baseUrl/authHeaders without
|
|
1037
|
+
// making them public — confined to this file's scope.
|
|
1038
|
+
const c = client as unknown as {
|
|
1039
|
+
baseUrl: string;
|
|
1040
|
+
authHeaders: (extra?: Record<string, string>) => Record<string, string>;
|
|
1041
|
+
};
|
|
1042
|
+
let lastLen = 0;
|
|
1043
|
+
for (;;) {
|
|
1044
|
+
if (isClosed()) return;
|
|
1045
|
+
try {
|
|
1046
|
+
const r = await fetch(`${c.baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
|
|
1047
|
+
headers: c.authHeaders(),
|
|
1048
|
+
});
|
|
1049
|
+
const j = (await r.json().catch(() => ({}))) as {
|
|
1050
|
+
status?: string;
|
|
1051
|
+
output?: string[] | string;
|
|
1052
|
+
};
|
|
1053
|
+
const all = Array.isArray(j.output) ? j.output : (j.output ? [j.output] : []);
|
|
1054
|
+
const flat = all.join('\n');
|
|
1055
|
+
if (flat.length > lastLen) {
|
|
1056
|
+
const fresh = flat.slice(lastLen);
|
|
1057
|
+
lastLen = flat.length;
|
|
1058
|
+
for (const ln of fresh.split('\n')) {
|
|
1059
|
+
if (ln.length > 0) onLine(ln);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (j.status && ['completed', 'failed', 'stopped'].includes(j.status)) {
|
|
1063
|
+
onComplete(j.status);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
} catch {
|
|
1067
|
+
// Transient — keep polling.
|
|
1068
|
+
}
|
|
1069
|
+
await new Promise((res) => setTimeout(res, 750));
|
|
1070
|
+
}
|
|
1071
|
+
}
|