yaver-feedback-react-native 0.8.12 → 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/DeployPanel.d.ts +7 -0
- package/dist/DeployPanel.js +354 -0
- package/dist/FeedbackModal.js +93 -5
- 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/_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/package.json +14 -2
- package/src/DeployPanel.tsx +403 -0
- package/src/FeedbackModal.tsx +125 -5
- package/src/FloatingButton.tsx +27 -3
- package/src/P2PClient.ts +247 -1
- package/src/VibeChatScreen.tsx +362 -0
- 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/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
|
+
}
|
|
@@ -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
|
+
});
|