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>
This commit is contained in:
2026-06-24 09:54:02 +02:00
parent 83fe089ea5
commit 72254e1d2d
7 changed files with 172 additions and 21 deletions
+3
View File
@@ -3,6 +3,7 @@ import { Pressable, Text } from "react-native";
import { Stack, router } from "expo-router"; import { Stack, router } from "expo-router";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import "../global.css"; import "../global.css";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
@@ -18,6 +19,7 @@ export default function RootLayout() {
}, [ready, settings.onboardingDone]); }, [ready, settings.onboardingDone]);
return ( return (
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}> <GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
<StatusBar style="light" /> <StatusBar style="light" />
<Stack <Stack
@@ -49,5 +51,6 @@ export default function RootLayout() {
<Stack.Screen name="onboarding" options={{ headerShown: false }} /> <Stack.Screen name="onboarding" options={{ headerShown: false }} />
</Stack> </Stack>
</GestureHandlerRootView> </GestureHandlerRootView>
</SafeAreaProvider>
); );
} }
+53 -17
View File
@@ -8,11 +8,13 @@ import {
Share, Share,
} from "react-native"; } from "react-native";
import { useLocalSearchParams, router, useNavigation } from "expo-router"; import { useLocalSearchParams, router, useNavigation } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { getIncidentById, updateIncident, deleteIncident, markIncidentPushed } from "@/lib/db"; import { getIncidentById, updateIncident, deleteIncident, markIncidentPushed } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown"; import { renderMarkdown } from "@/lib/markdown";
import { pushIncidentToGit, loadGitConfig } from "@/lib/git"; import { pushIncidentToGit, loadGitConfig, deleteIncidentFromGit } from "@/lib/git";
import type { GitConfig } from "@/lib/git";
import type { Incident } from "@/types/incident"; import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
@@ -41,11 +43,14 @@ export default function IncidentDetailScreen() {
const navigation = useNavigation(); const navigation = useNavigation();
const [incident, setIncident] = useState<Incident | null>(null); const [incident, setIncident] = useState<Incident | null>(null);
const [pushing, setPushing] = useState(false); const [pushing, setPushing] = useState(false);
const [gitConfig, setGitConfig] = useState<GitConfig | null>(null);
const insets = useSafeAreaInsets();
useEffect(() => { useEffect(() => {
if (id) { if (id) {
getIncidentById(id).then((inc) => { Promise.all([getIncidentById(id), loadGitConfig()]).then(([inc, config]) => {
setIncident(inc); setIncident(inc);
setGitConfig(config);
if (inc) { if (inc) {
navigation.setOptions({ title: inc.title || "Incident" }); navigation.setOptions({ title: inc.title || "Incident" });
} }
@@ -55,21 +60,52 @@ export default function IncidentDetailScreen() {
async function handleDelete() { async function handleDelete() {
if (!incident) return; if (!incident) return;
Alert.alert(
"Delete incident?", const canDeleteFromRepo = !!incident.gitPushedAt && gitConfig !== null;
"This action cannot be undone.",
[ const doDelete = async (deleteFromRepo: boolean) => {
{ text: "Cancel", style: "cancel" }, if (deleteFromRepo && gitConfig) {
{ const result = await deleteIncidentFromGit(incident, gitConfig);
text: "Delete", if (!result.success) {
style: "destructive", Alert.alert("Repo deletion failed", result.error ?? "Unknown error");
onPress: async () => { return;
await deleteIncident(incident.id); }
router.replace("/"); }
await deleteIncident(incident.id);
router.replace("/");
};
if (canDeleteFromRepo) {
Alert.alert(
"Delete incident?",
"This incident was pushed to the repository. Do you also want to delete the file from the repo?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Local only",
onPress: () => doDelete(false),
}, },
}, {
] text: "Local + Repo",
); style: "destructive",
onPress: () => doDelete(true),
},
]
);
} else {
Alert.alert(
"Delete incident?",
"This action cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => doDelete(false),
},
]
);
}
} }
async function handleResolve() { async function handleResolve() {
@@ -125,7 +161,7 @@ export default function IncidentDetailScreen() {
return ( return (
<ScrollView <ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }} style={{ flex: 1, backgroundColor: Colors.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: 32 }} contentContainerStyle={{ padding: 16, paddingBottom: 32 + insets.bottom }}
> >
{/* Status badge */} {/* Status badge */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 }}> <View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 }}>
+5 -2
View File
@@ -8,6 +8,7 @@ import {
Alert, Alert,
} from "react-native"; } from "react-native";
import { router, useFocusEffect } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { getIncidents, markIncidentPushed } from "@/lib/db"; import { getIncidents, markIncidentPushed } from "@/lib/db";
import { pushIncidentToGit, loadGitConfig } from "@/lib/git"; import { pushIncidentToGit, loadGitConfig } from "@/lib/git";
@@ -132,6 +133,7 @@ export default function HomeScreen() {
} }
const gitConfigured = gitConfig !== null; const gitConfigured = gitConfig !== null;
const insets = useSafeAreaInsets();
return ( return (
<View style={{ flex: 1, backgroundColor: Colors.bg }}> <View style={{ flex: 1, backgroundColor: Colors.bg }}>
@@ -141,7 +143,7 @@ export default function HomeScreen() {
<FlatList <FlatList
data={incidents} data={incidents}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 16, paddingBottom: 96 }} contentContainerStyle={{ padding: 16, paddingBottom: 96 + insets.bottom }}
ListEmptyComponent={ ListEmptyComponent={
<Text <Text
style={{ style={{
@@ -275,7 +277,7 @@ export default function HomeScreen() {
onPress={() => router.push("/new")} onPress={() => router.push("/new")}
style={{ style={{
position: "absolute", position: "absolute",
bottom: 24, bottom: 24 + insets.bottom,
right: 24, right: 24,
width: 56, width: 56,
height: 56, height: 56,
@@ -302,6 +304,7 @@ export default function HomeScreen() {
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: Colors.border, borderTopColor: Colors.border,
padding: 12, padding: 12,
paddingBottom: 12 + insets.bottom,
gap: 10, gap: 10,
}} }}
> >
+3 -1
View File
@@ -10,6 +10,7 @@ import {
Alert, Alert,
} from "react-native"; } from "react-native";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { import {
createIncident, createIncident,
@@ -55,6 +56,7 @@ type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix";
export default function NewIncidentScreen() { export default function NewIncidentScreen() {
const { editId } = useLocalSearchParams<{ editId?: string }>(); const { editId } = useLocalSearchParams<{ editId?: string }>();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [form, setForm] = useState<IncidentDraft>({ const [form, setForm] = useState<IncidentDraft>({
title: "", title: "",
@@ -250,7 +252,7 @@ export default function NewIncidentScreen() {
behavior={Platform.OS === "ios" ? "padding" : undefined} behavior={Platform.OS === "ios" ? "padding" : undefined}
> >
<ScrollView <ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 32 }} contentContainerStyle={{ padding: 16, paddingBottom: 32 + insets.bottom }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
{labelRow("Title *", "title")} {labelRow("Title *", "title")}
+4 -1
View File
@@ -7,6 +7,7 @@ import {
Switch, Switch,
Pressable, Pressable,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { DEFAULT_TEMPLATE } from "@/lib/markdown"; import { DEFAULT_TEMPLATE } from "@/lib/markdown";
@@ -213,6 +214,7 @@ function Field({
} }
export default function SettingsScreen() { export default function SettingsScreen() {
const insets = useSafeAreaInsets();
const [inputMode, setInputMode] = useState<"voice" | "form">("form"); const [inputMode, setInputMode] = useState<"voice" | "form">("form");
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard"); const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false); const [aiEnabled, setAiEnabled] = useState(false);
@@ -315,7 +317,7 @@ export default function SettingsScreen() {
return ( return (
<ScrollView <ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }} style={{ flex: 1, backgroundColor: Colors.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: 48 }} contentContainerStyle={{ padding: 16, paddingBottom: 48 + insets.bottom }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<Text style={SECTION_TITLE}>Preferences</Text> <Text style={SECTION_TITLE}>Preferences</Text>
@@ -401,6 +403,7 @@ export default function SettingsScreen() {
options={[ options={[
{ key: "anthropic", label: "Anthropic" }, { key: "anthropic", label: "Anthropic" },
{ key: "openai", label: "OpenAI" }, { key: "openai", label: "OpenAI" },
{ key: "mistral", label: "Mistral" },
{ key: "perplexity", label: "Perplexity" }, { key: "perplexity", label: "Perplexity" },
{ key: "ollama", label: "Ollama" }, { key: "ollama", label: "Ollama" },
{ key: "openrouter", label: "OpenRouter" }, { key: "openrouter", label: "OpenRouter" },
+3
View File
@@ -3,6 +3,7 @@ import * as SecureStore from "expo-secure-store";
export type AIProvider = export type AIProvider =
| "anthropic" | "anthropic"
| "openai" | "openai"
| "mistral"
| "perplexity" | "perplexity"
| "ollama" | "ollama"
| "openrouter" | "openrouter"
@@ -18,6 +19,7 @@ const KEYS = {
export const AI_PROVIDER_BASE_URLS: Record<AIProvider, string> = { export const AI_PROVIDER_BASE_URLS: Record<AIProvider, string> = {
anthropic: "https://api.anthropic.com", anthropic: "https://api.anthropic.com",
openai: "https://api.openai.com/v1", openai: "https://api.openai.com/v1",
mistral: "https://api.mistral.ai/v1",
perplexity: "https://api.perplexity.ai", perplexity: "https://api.perplexity.ai",
ollama: "http://localhost:11434/v1", ollama: "http://localhost:11434/v1",
openrouter: "https://openrouter.ai/api/v1", openrouter: "https://openrouter.ai/api/v1",
@@ -27,6 +29,7 @@ export const AI_PROVIDER_BASE_URLS: Record<AIProvider, string> = {
export const AI_PROVIDER_MODEL_PLACEHOLDERS: Record<AIProvider, string> = { export const AI_PROVIDER_MODEL_PLACEHOLDERS: Record<AIProvider, string> = {
anthropic: "claude-haiku-4-5-20251001", anthropic: "claude-haiku-4-5-20251001",
openai: "gpt-4o-mini", openai: "gpt-4o-mini",
mistral: "mistral-small-latest",
perplexity: "llama-3.1-sonar-small-128k-online", perplexity: "llama-3.1-sonar-small-128k-online",
ollama: "llama3.2", ollama: "llama3.2",
openrouter: "openai/gpt-4o-mini", openrouter: "openai/gpt-4o-mini",
+101
View File
@@ -18,6 +18,11 @@ export interface PushResult {
error?: string; error?: string;
} }
export interface DeleteResult {
success: boolean;
error?: string;
}
const TIMEOUT_MS = 15_000; const TIMEOUT_MS = 15_000;
export async function loadGitConfig(): Promise<GitConfig | null> { export async function loadGitConfig(): Promise<GitConfig | null> {
@@ -66,6 +71,20 @@ function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
); );
} }
export async function deleteIncidentFromGit(
incident: Incident,
config: GitConfig
): Promise<DeleteResult> {
switch (config.provider) {
case "gitea":
return deleteGitea(incident, config);
case "github":
return deleteGitHub(incident, config);
case "gitlab":
return deleteGitLab(incident, config);
}
}
export async function pushIncidentToGit( export async function pushIncidentToGit(
incident: Incident, incident: Incident,
config: GitConfig, config: GitConfig,
@@ -216,6 +235,88 @@ async function pushGitHub(
} }
} }
async function deleteGitea(incident: Incident, config: GitConfig): Promise<DeleteResult> {
if (!config.url) return { success: false, error: "Gitea instance URL is not set." };
const filepath = incidentToFilename(incident);
const apiBase = config.url.replace(/\/$/, "");
const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try {
const existing = await fetchWithTimeout(endpoint, {
headers: { Authorization: `token ${config.token}` },
});
if (existing.status === 404) return { success: true };
if (!existing.ok) return { success: false, error: `GET ${existing.status}` };
const data = await existing.json() as { sha?: string };
if (!data.sha) return { success: false, error: "No SHA returned by API." };
const res = await fetchWithTimeout(endpoint, {
method: "DELETE",
headers: { Authorization: `token ${config.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ message: `remove: ${incident.title}`, sha: data.sha, branch: "main" }),
});
if (!res.ok) {
const body = await res.text();
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 200)}` };
}
return { success: true };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` };
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
async function deleteGitHub(incident: Incident, config: GitConfig): Promise<DeleteResult> {
const filepath = incidentToFilename(incident);
const endpoint = `https://api.github.com/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try {
const existing = await fetchWithTimeout(endpoint, {
headers: { Authorization: `Bearer ${config.token}`, Accept: "application/vnd.github+json" },
});
if (existing.status === 404) return { success: true };
if (!existing.ok) return { success: false, error: `GET ${existing.status}` };
const data = await existing.json() as { sha?: string };
if (!data.sha) return { success: false, error: "No SHA returned by API." };
const res = await fetchWithTimeout(endpoint, {
method: "DELETE",
headers: { Authorization: `Bearer ${config.token}`, Accept: "application/vnd.github+json", "Content-Type": "application/json" },
body: JSON.stringify({ message: `remove: ${incident.title}`, sha: data.sha }),
});
if (!res.ok) {
const body = await res.text();
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 200)}` };
}
return { success: true };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` };
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
async function deleteGitLab(incident: Incident, config: GitConfig): Promise<DeleteResult> {
const filepath = incidentToFilename(incident);
const apiBase = (config.url ?? "https://gitlab.com").replace(/\/$/, "");
const projectId = encodeURIComponent(`${config.owner}/${config.repo}`);
const encodedPath = encodeURIComponent(filepath);
const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`;
try {
const res = await fetchWithTimeout(endpoint, {
method: "DELETE",
headers: { "PRIVATE-TOKEN": config.token, "Content-Type": "application/json" },
body: JSON.stringify({ branch: "main", commit_message: `remove: ${incident.title}` }),
});
if (res.status === 404) return { success: true };
if (!res.ok) {
const body = await res.text();
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 200)}` };
}
return { success: true };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` };
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
async function pushGitLab( async function pushGitLab(
incident: Incident, incident: Incident,
config: GitConfig, config: GitConfig,