diff --git a/app/_layout.tsx b/app/_layout.tsx
index dedeab5..2708d2e 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -3,6 +3,7 @@ import { Pressable, Text } from "react-native";
import { Stack, router } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler";
+import { SafeAreaProvider } from "react-native-safe-area-context";
import "../global.css";
import { Colors } from "@/constants/theme";
import { useSettings } from "@/hooks/useSettings";
@@ -18,6 +19,7 @@ export default function RootLayout() {
}, [ready, settings.onboardingDone]);
return (
+
+
);
}
diff --git a/app/incident/[id].tsx b/app/incident/[id].tsx
index 444d884..aab98f3 100644
--- a/app/incident/[id].tsx
+++ b/app/incident/[id].tsx
@@ -8,11 +8,13 @@ import {
Share,
} from "react-native";
import { useLocalSearchParams, router, useNavigation } from "expo-router";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Clipboard from "expo-clipboard";
import * as SecureStore from "expo-secure-store";
import { getIncidentById, updateIncident, deleteIncident, markIncidentPushed } from "@/lib/db";
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 { Colors } from "@/constants/theme";
@@ -41,11 +43,14 @@ export default function IncidentDetailScreen() {
const navigation = useNavigation();
const [incident, setIncident] = useState(null);
const [pushing, setPushing] = useState(false);
+ const [gitConfig, setGitConfig] = useState(null);
+ const insets = useSafeAreaInsets();
useEffect(() => {
if (id) {
- getIncidentById(id).then((inc) => {
+ Promise.all([getIncidentById(id), loadGitConfig()]).then(([inc, config]) => {
setIncident(inc);
+ setGitConfig(config);
if (inc) {
navigation.setOptions({ title: inc.title || "Incident" });
}
@@ -55,21 +60,52 @@ export default function IncidentDetailScreen() {
async function handleDelete() {
if (!incident) return;
- Alert.alert(
- "Delete incident?",
- "This action cannot be undone.",
- [
- { text: "Cancel", style: "cancel" },
- {
- text: "Delete",
- style: "destructive",
- onPress: async () => {
- await deleteIncident(incident.id);
- router.replace("/");
+
+ const canDeleteFromRepo = !!incident.gitPushedAt && gitConfig !== null;
+
+ const doDelete = async (deleteFromRepo: boolean) => {
+ if (deleteFromRepo && gitConfig) {
+ const result = await deleteIncidentFromGit(incident, gitConfig);
+ if (!result.success) {
+ Alert.alert("Repo deletion failed", result.error ?? "Unknown error");
+ return;
+ }
+ }
+ 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() {
@@ -125,7 +161,7 @@ export default function IncidentDetailScreen() {
return (
{/* Status badge */}
diff --git a/app/index.tsx b/app/index.tsx
index 9c026dc..e349476 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -8,6 +8,7 @@ import {
Alert,
} from "react-native";
import { router, useFocusEffect } from "expo-router";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store";
import { getIncidents, markIncidentPushed } from "@/lib/db";
import { pushIncidentToGit, loadGitConfig } from "@/lib/git";
@@ -132,6 +133,7 @@ export default function HomeScreen() {
}
const gitConfigured = gitConfig !== null;
+ const insets = useSafeAreaInsets();
return (
@@ -141,7 +143,7 @@ export default function HomeScreen() {
item.id}
- contentContainerStyle={{ padding: 16, paddingBottom: 96 }}
+ contentContainerStyle={{ padding: 16, paddingBottom: 96 + insets.bottom }}
ListEmptyComponent={
router.push("/new")}
style={{
position: "absolute",
- bottom: 24,
+ bottom: 24 + insets.bottom,
right: 24,
width: 56,
height: 56,
@@ -302,6 +304,7 @@ export default function HomeScreen() {
borderTopWidth: 1,
borderTopColor: Colors.border,
padding: 12,
+ paddingBottom: 12 + insets.bottom,
gap: 10,
}}
>
diff --git a/app/new.tsx b/app/new.tsx
index 95acbb6..d7943d4 100644
--- a/app/new.tsx
+++ b/app/new.tsx
@@ -10,6 +10,7 @@ import {
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,
@@ -55,6 +56,7 @@ 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({
title: "",
@@ -250,7 +252,7 @@ export default function NewIncidentScreen() {
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
{labelRow("Title *", "title")}
diff --git a/app/settings.tsx b/app/settings.tsx
index ae87c0a..790d0ca 100644
--- a/app/settings.tsx
+++ b/app/settings.tsx
@@ -7,6 +7,7 @@ import {
Switch,
Pressable,
} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store";
import { Colors } from "@/constants/theme";
import { DEFAULT_TEMPLATE } from "@/lib/markdown";
@@ -213,6 +214,7 @@ function Field({
}
export default function SettingsScreen() {
+ const insets = useSafeAreaInsets();
const [inputMode, setInputMode] = useState<"voice" | "form">("form");
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false);
@@ -315,7 +317,7 @@ export default function SettingsScreen() {
return (
Preferences
@@ -401,6 +403,7 @@ export default function SettingsScreen() {
options={[
{ key: "anthropic", label: "Anthropic" },
{ key: "openai", label: "OpenAI" },
+ { key: "mistral", label: "Mistral" },
{ key: "perplexity", label: "Perplexity" },
{ key: "ollama", label: "Ollama" },
{ key: "openrouter", label: "OpenRouter" },
diff --git a/lib/ai.ts b/lib/ai.ts
index dbf28f3..b48f44e 100644
--- a/lib/ai.ts
+++ b/lib/ai.ts
@@ -3,6 +3,7 @@ import * as SecureStore from "expo-secure-store";
export type AIProvider =
| "anthropic"
| "openai"
+ | "mistral"
| "perplexity"
| "ollama"
| "openrouter"
@@ -18,6 +19,7 @@ const KEYS = {
export const AI_PROVIDER_BASE_URLS: Record = {
anthropic: "https://api.anthropic.com",
openai: "https://api.openai.com/v1",
+ mistral: "https://api.mistral.ai/v1",
perplexity: "https://api.perplexity.ai",
ollama: "http://localhost:11434/v1",
openrouter: "https://openrouter.ai/api/v1",
@@ -27,6 +29,7 @@ export const AI_PROVIDER_BASE_URLS: Record = {
export const AI_PROVIDER_MODEL_PLACEHOLDERS: Record = {
anthropic: "claude-haiku-4-5-20251001",
openai: "gpt-4o-mini",
+ mistral: "mistral-small-latest",
perplexity: "llama-3.1-sonar-small-128k-online",
ollama: "llama3.2",
openrouter: "openai/gpt-4o-mini",
diff --git a/lib/git.ts b/lib/git.ts
index f0920a0..353ae0f 100644
--- a/lib/git.ts
+++ b/lib/git.ts
@@ -18,6 +18,11 @@ export interface PushResult {
error?: string;
}
+export interface DeleteResult {
+ success: boolean;
+ error?: string;
+}
+
const TIMEOUT_MS = 15_000;
export async function loadGitConfig(): Promise {
@@ -66,6 +71,20 @@ function fetchWithTimeout(url: string, init: RequestInit): Promise {
);
}
+export async function deleteIncidentFromGit(
+ incident: Incident,
+ config: GitConfig
+): Promise {
+ 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(
incident: Incident,
config: GitConfig,
@@ -216,6 +235,88 @@ async function pushGitHub(
}
}
+async function deleteGitea(incident: Incident, config: GitConfig): Promise {
+ 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 {
+ 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 {
+ 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(
incident: Incident,
config: GitConfig,