Files
billisdead 9f163d3b76
Release APK / build (push) Has been cancelled
feat: safe area fix, delete from repo, add Mistral provider
- Wrap root layout with SafeAreaProvider, use useSafeAreaInsets in all
  screens so FAB/action bar/scroll content clear Android nav bar
- Add deleteIncidentFromGit() (Gitea/GitHub/GitLab) — detail screen now
  offers Cancel / Local only / Local + Repo when incident was pushed
- Add Mistral as AI provider (OpenAI-compat, api.mistral.ai/v1,
  mistral-small-latest default)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 09:54:02 +02:00

430 lines
12 KiB
TypeScript

import { useState, useEffect, useRef } from "react";
import {
View,
Text,
TextInput,
ScrollView,
Pressable,
KeyboardAvoidingView,
Platform,
Alert,
} from "react-native";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store";
import {
createIncident,
updateIncident,
getIncidentById,
getDistinctServices,
getMaxTitleNumber,
} from "@/lib/db";
import { Colors } from "@/constants/theme";
import { useVoice } from "@/hooks/useVoice";
import { callAI } from "@/lib/ai";
import type { IncidentDraft } from "@/types/incident";
const FIELD_STYLE = {
backgroundColor: Colors.surface,
borderColor: Colors.border,
borderWidth: 1,
borderRadius: 8,
color: Colors.text1,
padding: 12,
fontSize: 14,
marginBottom: 16,
} as const;
const MONO_FIELD_STYLE = {
...FIELD_STYLE,
fontFamily: "monospace",
minHeight: 80,
textAlignVertical: "top" as const,
} as const;
const LABEL_STYLE = {
color: Colors.text2,
fontSize: 12,
fontWeight: "600" as const,
textTransform: "uppercase" as const,
letterSpacing: 0.5,
} as const;
type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix";
export default function NewIncidentScreen() {
const { editId } = useLocalSearchParams<{ editId?: string }>();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [form, setForm] = useState<IncidentDraft>({
title: "",
service: "",
symptom: "",
rootCause: "",
fix: "",
status: "open",
tags: [],
});
const [tagInput, setTagInput] = useState("");
const [saving, setSaving] = useState(false);
const [suggesting, setSuggesting] = useState(false);
const [suggestError, setSuggestError] = useState("");
const [aiEnabled, setAiEnabled] = useState(false);
const [knownServices, setKnownServices] = useState<string[]>([]);
const [serviceFocused, setServiceFocused] = useState(false);
const { state: voiceState, transcript, start: startVoice, stop: stopVoice } = useVoice();
const voiceFieldRef = useRef<VoiceField | null>(null);
const [listeningField, setListeningField] = useState<VoiceField | null>(null);
useEffect(() => {
getDistinctServices().then(setKnownServices);
SecureStore.getItemAsync("pref_ai_enabled").then((v) => setAiEnabled(v === "true"));
}, []);
useEffect(() => {
if (editId) return;
SecureStore.getItemAsync("pref_title_template").then((tpl) => {
if (tpl) setForm((f) => ({ ...f, title: tpl }));
});
}, [editId]);
useEffect(() => {
if (!editId) return;
navigation.setOptions({ title: "Edit Incident" });
getIncidentById(editId).then((inc) => {
if (!inc) return;
setForm({
title: inc.title,
service: inc.service,
symptom: inc.symptom,
rootCause: inc.rootCause,
fix: inc.fix,
status: inc.status,
tags: inc.tags,
});
});
}, [editId]);
// Live-fill the active voice field as transcript updates
useEffect(() => {
const field = voiceFieldRef.current;
if (transcript && field) {
setForm((f) => ({ ...f, [field]: transcript }));
}
}, [transcript]);
// Clear listening state when recognition ends
useEffect(() => {
if ((voiceState === "idle" || voiceState === "error") && voiceFieldRef.current !== null) {
voiceFieldRef.current = null;
setListeningField(null);
}
}, [voiceState]);
function handleMic(field: VoiceField) {
if (listeningField !== null) {
stopVoice();
return;
}
voiceFieldRef.current = field;
setListeningField(field);
startVoice("fr-FR");
}
function set(key: keyof IncidentDraft) {
return (value: string) => setForm((f) => ({ ...f, [key]: value }));
}
function handleTagInputEnd() {
const trimmed = tagInput.trim();
if (trimmed && !form.tags.includes(trimmed)) {
setForm((f) => ({ ...f, tags: [...f.tags, trimmed] }));
}
setTagInput("");
}
function removeTag(tag: string) {
setForm((f) => ({ ...f, tags: f.tags.filter((t) => t !== tag) }));
}
async function handleSuggest() {
setSuggesting(true);
setSuggestError("");
try {
const result = await callAI(form.title, form.symptom);
setForm((f) => ({ ...f, rootCause: result.rootCause, fix: result.fix }));
} catch (e) {
setSuggestError(e instanceof Error ? e.message : "AI call failed");
} finally {
setSuggesting(false);
}
}
async function handleSave() {
if (!form.title.trim()) {
Alert.alert("Required", "Title is required.");
return;
}
setSaving(true);
try {
if (editId) {
await updateIncident(editId, form);
} else {
await createIncident(form);
const tpl = await SecureStore.getItemAsync("pref_title_template");
if (tpl) {
const match = tpl.match(/^([\s\S]*?)(\d+)$/);
if (match) {
const [, prefix, numStr] = match;
const dbMax = await getMaxTitleNumber(prefix);
const tplNum = parseInt(numStr, 10);
const next = (Math.max(dbMax, tplNum) + 1).toString().padStart(numStr.length, "0");
await SecureStore.setItemAsync("pref_title_template", prefix + next);
}
}
}
router.back();
} catch {
Alert.alert("Error", "Failed to save incident.");
} finally {
setSaving(false);
}
}
const filteredServices =
serviceFocused && form.service.length > 0
? knownServices.filter(
(s) =>
s.toLowerCase().includes(form.service.toLowerCase()) &&
s !== form.service
)
: [];
function micButton(field: VoiceField) {
const active = listeningField === field;
return (
<Pressable
onPress={() => handleMic(field)}
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 4,
backgroundColor: active ? "#ef444420" : Colors.surface2,
borderWidth: 1,
borderColor: active ? Colors.danger : Colors.border,
}}
>
<Text
style={{
color: active ? Colors.danger : Colors.text2,
fontSize: 11,
fontWeight: "700",
}}
>
{active ? "STOP" : "MIC"}
</Text>
</Pressable>
);
}
function labelRow(label: string, field: VoiceField) {
return (
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
}}
>
<Text style={LABEL_STYLE}>{label}</Text>
{micButton(field)}
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: Colors.bg }}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 32 + insets.bottom }}
keyboardShouldPersistTaps="handled"
>
{labelRow("Title *", "title")}
<TextInput
style={FIELD_STYLE}
value={form.title}
onChangeText={set("title")}
placeholder="e.g. Ghost blog down after k3s node drain"
placeholderTextColor={Colors.textDim}
returnKeyType="next"
/>
{labelRow("Service", "service")}
<TextInput
style={FIELD_STYLE}
value={form.service}
onChangeText={set("service")}
onFocus={() => setServiceFocused(true)}
onBlur={() => setTimeout(() => setServiceFocused(false), 150)}
placeholder="e.g. ghost-blog, k3s-cluster, haproxy"
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
returnKeyType="next"
/>
{filteredServices.length > 0 && (
<View
style={{
backgroundColor: Colors.surface2,
borderRadius: 8,
borderWidth: 1,
borderColor: Colors.border,
marginTop: -12,
marginBottom: 16,
overflow: "hidden",
}}
>
{filteredServices.slice(0, 5).map((s, i) => (
<Pressable
key={s}
onPress={() => {
setForm((f) => ({ ...f, service: s }));
setServiceFocused(false);
}}
style={{
padding: 12,
borderTopWidth: i === 0 ? 0 : 1,
borderTopColor: Colors.border,
}}
>
<Text style={{ color: Colors.text1, fontSize: 14 }}>{s}</Text>
</Pressable>
))}
</View>
)}
{labelRow("Symptom", "symptom")}
<TextInput
style={MONO_FIELD_STYLE}
value={form.symptom}
onChangeText={set("symptom")}
placeholder="What was observed?"
placeholderTextColor={Colors.textDim}
multiline
returnKeyType="next"
/>
{aiEnabled && form.title.trim() && form.symptom.trim() && (
<View style={{ marginBottom: 16 }}>
<Pressable
onPress={handleSuggest}
disabled={suggesting}
style={{
backgroundColor: suggesting ? Colors.surface2 : Colors.surface,
borderRadius: 8,
padding: 12,
alignItems: "center",
borderWidth: 1,
borderColor: Colors.primary,
flexDirection: "row",
justifyContent: "center",
gap: 8,
}}
>
<Text style={{ color: Colors.primary, fontSize: 14, fontWeight: "700" }}>
{suggesting ? "Analyzing…" : "✦ Suggest root cause & fix"}
</Text>
</Pressable>
{suggestError !== "" && (
<Text style={{ color: Colors.danger, fontSize: 12, marginTop: 6 }}>
{suggestError}
</Text>
)}
</View>
)}
{labelRow("Root Cause", "rootCause")}
<TextInput
style={MONO_FIELD_STYLE}
value={form.rootCause}
onChangeText={set("rootCause")}
placeholder="Why did it happen?"
placeholderTextColor={Colors.textDim}
multiline
returnKeyType="next"
/>
{labelRow("Fix Applied", "fix")}
<TextInput
style={MONO_FIELD_STYLE}
value={form.fix}
onChangeText={set("fix")}
placeholder="What did you do to fix it?"
placeholderTextColor={Colors.textDim}
multiline
returnKeyType="done"
/>
<Text style={[LABEL_STYLE, { marginBottom: 6 }]}>Tags</Text>
<TextInput
style={FIELD_STYLE}
value={tagInput}
onChangeText={setTagInput}
onSubmitEditing={handleTagInputEnd}
onBlur={handleTagInputEnd}
placeholder="Type tag and press Enter"
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
returnKeyType="done"
blurOnSubmit={false}
/>
{form.tags.length > 0 && (
<View
style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 16 }}
>
{form.tags.map((tag) => (
<Pressable
key={tag}
onPress={() => removeTag(tag)}
style={{
backgroundColor: Colors.surface2,
borderRadius: 4,
paddingHorizontal: 10,
paddingVertical: 4,
flexDirection: "row",
alignItems: "center",
gap: 6,
}}
>
<Text style={{ color: Colors.text2, fontSize: 13 }}>{tag}</Text>
<Text style={{ color: Colors.textDim, fontSize: 12 }}>x</Text>
</Pressable>
))}
</View>
)}
<Pressable
onPress={handleSave}
disabled={saving}
style={{
backgroundColor: saving ? Colors.primaryDim : Colors.primary,
borderRadius: 10,
padding: 16,
alignItems: "center",
marginTop: 8,
}}
>
<Text style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}>
{saving ? "Saving…" : editId ? "Save Changes" : "Save Incident"}
</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}