Files
timmy-tower/artifacts/mobile/components/JobSubmissionSheet.tsx
2026-03-23 20:20:52 +00:00

738 lines
19 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Animated,
Dimensions,
Keyboard,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import QRCode from "react-native-qrcode-svg";
import { Colors } from "@/constants/colors";
const C = Colors.dark;
const POLL_INTERVAL = 3000;
const SCREEN_WIDTH = Dimensions.get("window").width;
const QR_SIZE = Math.min(SCREEN_WIDTH - 80, 240);
const BASE_URL = process.env.EXPO_PUBLIC_DOMAIN ?? "";
function getApiBase(): string {
let domain = BASE_URL;
if (!domain) domain = "localhost:8080";
domain = domain.replace(/\/$/, "");
if (!/^https?:\/\//.test(domain)) {
const proto = domain.startsWith("localhost") ? "http" : "https";
domain = `${proto}://${domain}`;
}
return domain;
}
type JobState =
| "awaiting_eval_payment"
| "evaluating"
| "rejected"
| "awaiting_work_payment"
| "executing"
| "complete"
| "failed";
type InvoiceInfo = {
paymentRequest: string;
amountSats: number;
};
type JobStatus = {
jobId: string;
state: JobState;
evalInvoice?: InvoiceInfo;
workInvoice?: InvoiceInfo;
result?: string;
reason?: string;
errorMessage?: string;
};
type CreateJobResponse = {
jobId: string;
evalInvoice: InvoiceInfo;
};
type EstimateResponse = {
estimatedCostSats: number;
estimatedCostUsd: number;
btcPriceUsd: number;
};
const STATE_LABELS: Record<string, string> = {
awaiting_eval_payment: "Awaiting eval payment",
evaluating: "Evaluating your request...",
rejected: "Request rejected",
awaiting_work_payment: "Awaiting work payment",
executing: "Executing job...",
complete: "Complete!",
failed: "Job failed",
};
const STATE_ICONS: Record<string, string> = {
awaiting_eval_payment: "flash-outline",
evaluating: "hourglass-outline",
rejected: "close-circle-outline",
awaiting_work_payment: "flash-outline",
executing: "cog-outline",
complete: "checkmark-circle-outline",
failed: "alert-circle-outline",
};
type Props = {
visible: boolean;
onClose: () => void;
};
export function JobSubmissionSheet({ visible, onClose }: Props) {
const insets = useSafeAreaInsets();
const [prompt, setPrompt] = useState("");
const [estimate, setEstimate] = useState<EstimateResponse | null>(null);
const [estimateLoading, setEstimateLoading] = useState(false);
const [estimateError, setEstimateError] = useState("");
const [jobId, setJobId] = useState<string | null>(null);
const [jobStatus, setJobStatus] = useState<JobStatus | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
const [resultExpanded, setResultExpanded] = useState(false);
const slideAnim = useRef(new Animated.Value(0)).current;
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Slide animation
useEffect(() => {
Animated.timing(slideAnim, {
toValue: visible ? 1 : 0,
duration: 300,
useNativeDriver: true,
}).start();
}, [visible, slideAnim]);
// Polling for job status
useEffect(() => {
if (!jobId) return;
const poll = async () => {
try {
const res = await fetch(`${getApiBase()}/api/jobs/${jobId}`);
if (!res.ok) return;
const data = (await res.json()) as JobStatus;
setJobStatus(data);
// Stop polling on terminal states
if (
data.state === "complete" ||
data.state === "failed" ||
data.state === "rejected"
) {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}
} catch {
// ignore poll errors
}
};
// Immediately fetch once
void poll();
pollRef.current = setInterval(poll, POLL_INTERVAL);
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, [jobId]);
const resetState = useCallback(() => {
setPrompt("");
setEstimate(null);
setEstimateError("");
setJobId(null);
setJobStatus(null);
setSubmitting(false);
setSubmitError("");
setResultExpanded(false);
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
const handleClose = useCallback(() => {
resetState();
onClose();
}, [onClose, resetState]);
const handleEstimate = useCallback(async () => {
if (!prompt.trim()) return;
Keyboard.dismiss();
setEstimateLoading(true);
setEstimateError("");
setEstimate(null);
try {
const res = await fetch(`${getApiBase()}/api/jobs/estimate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request: prompt.trim() }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Request failed" }));
setEstimateError(
(err as { error?: string }).error ?? `HTTP ${res.status}`
);
return;
}
const data = (await res.json()) as EstimateResponse;
setEstimate(data);
} catch (e) {
setEstimateError(
e instanceof Error ? e.message : "Failed to get estimate"
);
} finally {
setEstimateLoading(false);
}
}, [prompt]);
const handleSubmit = useCallback(async () => {
if (!prompt.trim()) return;
Keyboard.dismiss();
setSubmitting(true);
setSubmitError("");
try {
const res = await fetch(`${getApiBase()}/api/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request: prompt.trim() }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Request failed" }));
setSubmitError(
(err as { error?: string }).error ?? `HTTP ${res.status}`
);
return;
}
const data = (await res.json()) as CreateJobResponse;
setJobId(data.jobId);
setJobStatus({
jobId: data.jobId,
state: "awaiting_eval_payment",
evalInvoice: data.evalInvoice,
});
} catch (e) {
setSubmitError(
e instanceof Error ? e.message : "Failed to submit job"
);
} finally {
setSubmitting(false);
}
}, [prompt]);
// Determine which invoice to show
const activeInvoice: InvoiceInfo | undefined =
jobStatus?.state === "awaiting_work_payment"
? jobStatus.workInvoice
: jobStatus?.state === "awaiting_eval_payment"
? jobStatus.evalInvoice
: undefined;
const isTerminal =
jobStatus?.state === "complete" ||
jobStatus?.state === "failed" ||
jobStatus?.state === "rejected";
const isPolling = jobId != null && !isTerminal;
const renderContent = () => {
// === JOB IN PROGRESS / COMPLETE ===
if (jobId && jobStatus) {
return (
<ScrollView
style={styles.sheetScroll}
contentContainerStyle={styles.sheetScrollContent}
keyboardShouldPersistTaps="handled"
>
{/* Status badge */}
<View style={styles.statusRow}>
<Ionicons
name={
(STATE_ICONS[jobStatus.state] ?? "help-outline") as React.ComponentProps<typeof Ionicons>["name"]
}
size={20}
color={
jobStatus.state === "complete"
? C.jobCompleted
: jobStatus.state === "failed" || jobStatus.state === "rejected"
? C.error
: C.jobStarted
}
/>
<Text
style={[
styles.statusText,
jobStatus.state === "complete" && { color: C.jobCompleted },
(jobStatus.state === "failed" ||
jobStatus.state === "rejected") && { color: C.error },
]}
>
{STATE_LABELS[jobStatus.state] ?? jobStatus.state}
</Text>
{isPolling && (
<ActivityIndicator size="small" color={C.accent} />
)}
</View>
{/* Invoice QR */}
{activeInvoice && (
<View style={styles.qrContainer}>
<Text style={styles.qrLabel}>
{jobStatus.state === "awaiting_eval_payment"
? "Pay eval invoice"
: "Pay work invoice"}
</Text>
<View style={styles.qrWrapper}>
<QRCode
value={activeInvoice.paymentRequest}
size={QR_SIZE}
backgroundColor="#FFFFFF"
color="#000000"
/>
</View>
<Text style={styles.satsLabel}>
{activeInvoice.amountSats} sats
</Text>
<Text style={styles.invoiceHint}>
Scan with your Lightning wallet
</Text>
</View>
)}
{/* Rejection reason */}
{jobStatus.state === "rejected" && jobStatus.reason && (
<View style={styles.errorCard}>
<Ionicons name="close-circle" size={16} color={C.error} />
<Text style={styles.errorCardText}>{jobStatus.reason}</Text>
</View>
)}
{/* Error message */}
{jobStatus.state === "failed" && jobStatus.errorMessage && (
<View style={styles.errorCard}>
<Ionicons name="alert-circle" size={16} color={C.error} />
<Text style={styles.errorCardText}>
{jobStatus.errorMessage}
</Text>
</View>
)}
{/* Result */}
{jobStatus.state === "complete" && jobStatus.result && (
<Pressable
style={styles.resultCard}
onPress={() => setResultExpanded((v) => !v)}
>
<View style={styles.resultHeader}>
<Ionicons
name="checkmark-circle"
size={18}
color={C.jobCompleted}
/>
<Text style={styles.resultTitle}>Result</Text>
<Ionicons
name={resultExpanded ? "chevron-up" : "chevron-down"}
size={16}
color={C.textSecondary}
/>
</View>
<Text
style={styles.resultText}
numberOfLines={resultExpanded ? undefined : 6}
>
{jobStatus.result}
</Text>
</Pressable>
)}
{/* New job button for terminal states */}
{isTerminal && (
<Pressable style={styles.newJobButton} onPress={resetState}>
<Text style={styles.newJobButtonText}>Submit another job</Text>
</Pressable>
)}
</ScrollView>
);
}
// === INPUT FORM ===
return (
<ScrollView
style={styles.sheetScroll}
contentContainerStyle={styles.sheetScrollContent}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.inputLabel}>What should Timmy do?</Text>
<TextInput
style={styles.textInput}
placeholder="Describe your job..."
placeholderTextColor={C.textMuted}
value={prompt}
onChangeText={setPrompt}
multiline
numberOfLines={4}
textAlignVertical="top"
maxLength={2000}
autoFocus
/>
{/* Estimate result */}
{estimate && (
<View style={styles.estimateCard}>
<Ionicons name="flash" size={16} color={C.jobStarted} />
<Text style={styles.estimateText}>
Estimated cost: {estimate.estimatedCostSats} sats (~$
{estimate.estimatedCostUsd.toFixed(4)})
</Text>
</View>
)}
{estimateError ? (
<View style={styles.errorCard}>
<Ionicons name="alert-circle" size={16} color={C.error} />
<Text style={styles.errorCardText}>{estimateError}</Text>
</View>
) : null}
{submitError ? (
<View style={styles.errorCard}>
<Ionicons name="alert-circle" size={16} color={C.error} />
<Text style={styles.errorCardText}>{submitError}</Text>
</View>
) : null}
{/* Action buttons */}
<View style={styles.buttonRow}>
<Pressable
style={[
styles.estimateButton,
(!prompt.trim() || estimateLoading) && styles.buttonDisabled,
]}
onPress={handleEstimate}
disabled={!prompt.trim() || estimateLoading}
>
{estimateLoading ? (
<ActivityIndicator size="small" color={C.accent} />
) : (
<>
<Ionicons name="calculator-outline" size={18} color={C.accent} />
<Text style={styles.estimateButtonText}>Estimate</Text>
</>
)}
</Pressable>
<Pressable
style={[
styles.submitButton,
(!prompt.trim() || submitting) && styles.buttonDisabled,
]}
onPress={handleSubmit}
disabled={!prompt.trim() || submitting}
>
{submitting ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons name="flash" size={18} color="#fff" />
<Text style={styles.submitButtonText}>Submit Job</Text>
</>
)}
</Pressable>
</View>
</ScrollView>
);
};
return (
<Modal
visible={visible}
animationType="slide"
transparent
onRequestClose={handleClose}
>
<View style={styles.overlay}>
<Pressable style={styles.overlayBackdrop} onPress={handleClose} />
<Animated.View
style={[
styles.sheet,
{
paddingBottom: Math.max(insets.bottom, 16),
transform: [
{
translateY: slideAnim.interpolate({
inputRange: [0, 1],
outputRange: [600, 0],
}),
},
],
},
]}
>
{/* Handle bar */}
<View style={styles.handleRow}>
<View style={styles.handle} />
</View>
{/* Header */}
<View style={styles.sheetHeader}>
<Ionicons name="flash" size={22} color={C.jobStarted} />
<Text style={styles.sheetTitle}>Submit Job</Text>
<Pressable
onPress={handleClose}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel="Close"
>
<Ionicons name="close" size={24} color={C.textSecondary} />
</Pressable>
</View>
{renderContent()}
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: "flex-end",
},
overlayBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.5)",
},
sheet: {
backgroundColor: C.surface,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: "85%",
borderWidth: 1,
borderBottomWidth: 0,
borderColor: C.border,
},
handleRow: {
alignItems: "center",
paddingTop: 10,
paddingBottom: 4,
},
handle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: C.textMuted,
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 12,
gap: 10,
borderBottomWidth: 1,
borderBottomColor: C.border,
},
sheetTitle: {
flex: 1,
fontSize: 18,
fontFamily: "Inter_600SemiBold",
color: C.text,
},
sheetScroll: {
flex: 1,
},
sheetScrollContent: {
padding: 20,
gap: 16,
},
inputLabel: {
fontSize: 14,
fontFamily: "Inter_500Medium",
color: C.textSecondary,
},
textInput: {
backgroundColor: C.surfaceElevated,
borderRadius: 12,
borderWidth: 1,
borderColor: C.border,
color: C.text,
fontFamily: "Inter_400Regular",
fontSize: 15,
padding: 14,
minHeight: 100,
...(Platform.OS === "web" ? { outlineStyle: "none" as unknown as undefined } : {}),
},
estimateCard: {
flexDirection: "row",
alignItems: "center",
gap: 8,
backgroundColor: C.surfaceElevated,
borderRadius: 10,
padding: 12,
borderWidth: 1,
borderColor: C.jobStarted + "44",
},
estimateText: {
fontSize: 14,
fontFamily: "Inter_500Medium",
color: C.jobStarted,
flex: 1,
},
errorCard: {
flexDirection: "row",
alignItems: "flex-start",
gap: 8,
backgroundColor: C.error + "18",
borderRadius: 10,
padding: 12,
borderWidth: 1,
borderColor: C.error + "44",
},
errorCardText: {
fontSize: 13,
fontFamily: "Inter_400Regular",
color: C.error,
flex: 1,
},
buttonRow: {
flexDirection: "row",
gap: 12,
},
estimateButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
backgroundColor: C.surfaceElevated,
borderRadius: 12,
paddingVertical: 14,
borderWidth: 1,
borderColor: C.accent + "44",
},
estimateButtonText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
color: C.accent,
},
submitButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
backgroundColor: C.accent,
borderRadius: 12,
paddingVertical: 14,
},
submitButtonText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
color: "#fff",
},
buttonDisabled: {
opacity: 0.5,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
backgroundColor: C.surfaceElevated,
borderRadius: 10,
padding: 12,
borderWidth: 1,
borderColor: C.border,
},
statusText: {
flex: 1,
fontSize: 14,
fontFamily: "Inter_500Medium",
color: C.text,
},
qrContainer: {
alignItems: "center",
gap: 12,
},
qrLabel: {
fontSize: 14,
fontFamily: "Inter_500Medium",
color: C.textSecondary,
},
qrWrapper: {
backgroundColor: "#FFFFFF",
padding: 16,
borderRadius: 12,
},
satsLabel: {
fontSize: 20,
fontFamily: "Inter_700Bold",
color: C.jobStarted,
},
invoiceHint: {
fontSize: 12,
fontFamily: "Inter_400Regular",
color: C.textMuted,
},
resultCard: {
backgroundColor: C.surfaceElevated,
borderRadius: 12,
padding: 14,
borderWidth: 1,
borderColor: C.jobCompleted + "44",
gap: 10,
},
resultHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
resultTitle: {
flex: 1,
fontSize: 15,
fontFamily: "Inter_600SemiBold",
color: C.text,
},
resultText: {
fontSize: 14,
fontFamily: "Inter_400Regular",
color: C.text,
lineHeight: 20,
},
newJobButton: {
alignItems: "center",
paddingVertical: 14,
backgroundColor: C.surfaceElevated,
borderRadius: 12,
borderWidth: 1,
borderColor: C.border,
},
newJobButtonText: {
fontSize: 15,
fontFamily: "Inter_500Medium",
color: C.accent,
},
});