feat: delete incident, fix git push, title template, clarify AI settings
Release APK / build (push) Has been cancelled
Release APK / build (push) Has been cancelled
- IncidentDetailScreen: add Delete button (confirm dialog) wired to deleteIncident() - git.ts: fix Gitea POST vs PUT (use PUT when file exists/sha present); add pre-flight validation for missing URL/token/owner/repo; add 15s fetch timeout with AbortError handling; improve error messages with actionable hints - [id].tsx: guard URL for Gitea/GitLab before calling pushIncidentToGit - settings.tsx: move ToggleRow/SegmentRow/Field outside SettingsScreen to fix TextInput focus loss on each keystroke (component remount on rerender) - settings.tsx: add Title Template setting with auto-increment info card - settings.tsx: add AI base URL + model fields; expand AI section description - new.tsx: pre-fill title from pref_title_template; increment counter after save Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+218
-146
@@ -17,18 +17,18 @@ const KEYS = {
|
||||
HOME_VIEW: "pref_home_view",
|
||||
AI_ENABLED: "pref_ai_enabled",
|
||||
AI_KEY: "pref_ai_key",
|
||||
AI_BASE_URL: "pref_ai_base_url",
|
||||
AI_MODEL: "pref_ai_model",
|
||||
TITLE_TEMPLATE: "pref_title_template",
|
||||
MD_TEMPLATE: "md_template",
|
||||
GIT_PROVIDER: "git_provider",
|
||||
// Gitea
|
||||
GITEA_URL: "gitea_url",
|
||||
GITEA_TOKEN: "gitea_token",
|
||||
GITEA_OWNER: "gitea_owner",
|
||||
GITEA_REPO: "gitea_repo",
|
||||
// GitHub
|
||||
GITHUB_TOKEN: "github_token",
|
||||
GITHUB_OWNER: "github_owner",
|
||||
GITHUB_REPO: "github_repo",
|
||||
// GitLab
|
||||
GITLAB_URL: "gitlab_url",
|
||||
GITLAB_TOKEN: "gitlab_token",
|
||||
GITLAB_OWNER: "gitlab_owner",
|
||||
@@ -70,26 +70,140 @@ const PROVIDER_LABELS: Record<GitProvider, string> = {
|
||||
gitlab: "GitLab",
|
||||
};
|
||||
|
||||
// Defined outside SettingsScreen — prevents remount on every rerender, which
|
||||
// would cause TextInput to lose focus after each keystroke.
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: boolean;
|
||||
onValueChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
backgroundColor: Colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: Colors.text1, fontSize: 15 }}>{label}</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ true: Colors.primary, false: Colors.border }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentRow({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: { key: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text style={LABEL}>{label}</Text>
|
||||
<View style={{ flexDirection: "row", gap: 8 }}>
|
||||
{options.map((opt) => (
|
||||
<Pressable
|
||||
key={opt.key}
|
||||
onPress={() => onChange(opt.key)}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor:
|
||||
value === opt.key ? Colors.primary : Colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor:
|
||||
value === opt.key ? Colors.primary : Colors.border,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: value === opt.key ? Colors.bg : Colors.text2,
|
||||
fontWeight: "700",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
secure,
|
||||
url,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
secure?: boolean;
|
||||
url?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Text style={LABEL}>{label}</Text>
|
||||
<TextInput
|
||||
style={INPUT}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={Colors.textDim}
|
||||
secureTextEntry={secure}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType={url ? "url" : "default"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const [inputMode, setInputMode] = useState<"voice" | "form">("form");
|
||||
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [aiKey, setAiKey] = useState("");
|
||||
const [aiBaseUrl, setAiBaseUrl] = useState("");
|
||||
const [aiModel, setAiModel] = useState("");
|
||||
const [titleTemplate, setTitleTemplate] = useState("");
|
||||
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Git provider
|
||||
const [gitProvider, setGitProvider] = useState<GitProvider>("gitea");
|
||||
// Gitea
|
||||
const [giteaUrl, setGiteaUrl] = useState("");
|
||||
const [giteaToken, setGiteaToken] = useState("");
|
||||
const [giteaOwner, setGiteaOwner] = useState("");
|
||||
const [giteaRepo, setGiteaRepo] = useState("");
|
||||
// GitHub
|
||||
const [githubToken, setGithubToken] = useState("");
|
||||
const [githubOwner, setGithubOwner] = useState("");
|
||||
const [githubRepo, setGithubRepo] = useState("");
|
||||
// GitLab
|
||||
const [gitlabUrl, setGitlabUrl] = useState("");
|
||||
const [gitlabToken, setGitlabToken] = useState("");
|
||||
const [gitlabOwner, setGitlabOwner] = useState("");
|
||||
@@ -97,30 +211,40 @@ export default function SettingsScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [im, hv, ai, key, tpl, gp, gu, gt, go, gr, ghu, ghown, ghrepo, glu, glt, glo, glr] =
|
||||
await Promise.all([
|
||||
SecureStore.getItemAsync(KEYS.INPUT_MODE),
|
||||
SecureStore.getItemAsync(KEYS.HOME_VIEW),
|
||||
SecureStore.getItemAsync(KEYS.AI_ENABLED),
|
||||
SecureStore.getItemAsync(KEYS.AI_KEY),
|
||||
SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
|
||||
SecureStore.getItemAsync(KEYS.GIT_PROVIDER),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_URL),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_OWNER),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_REPO),
|
||||
SecureStore.getItemAsync(KEYS.GITHUB_TOKEN),
|
||||
SecureStore.getItemAsync(KEYS.GITHUB_OWNER),
|
||||
SecureStore.getItemAsync(KEYS.GITHUB_REPO),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_URL),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_TOKEN),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_OWNER),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_REPO),
|
||||
]);
|
||||
const [
|
||||
im, hv, ai, key, aiUrl, aiMdl, titleTpl, tpl,
|
||||
gp, gu, gt, go, gr,
|
||||
ghu, ghown, ghrepo,
|
||||
glu, glt, glo, glr,
|
||||
] = await Promise.all([
|
||||
SecureStore.getItemAsync(KEYS.INPUT_MODE),
|
||||
SecureStore.getItemAsync(KEYS.HOME_VIEW),
|
||||
SecureStore.getItemAsync(KEYS.AI_ENABLED),
|
||||
SecureStore.getItemAsync(KEYS.AI_KEY),
|
||||
SecureStore.getItemAsync(KEYS.AI_BASE_URL),
|
||||
SecureStore.getItemAsync(KEYS.AI_MODEL),
|
||||
SecureStore.getItemAsync(KEYS.TITLE_TEMPLATE),
|
||||
SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
|
||||
SecureStore.getItemAsync(KEYS.GIT_PROVIDER),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_URL),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_OWNER),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_REPO),
|
||||
SecureStore.getItemAsync(KEYS.GITHUB_TOKEN),
|
||||
SecureStore.getItemAsync(KEYS.GITHUB_OWNER),
|
||||
SecureStore.getItemAsync(KEYS.GITHUB_REPO),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_URL),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_TOKEN),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_OWNER),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_REPO),
|
||||
]);
|
||||
if (im) setInputMode(im as "voice" | "form");
|
||||
if (hv) setHomeView(hv as "dashboard" | "capture");
|
||||
if (ai) setAiEnabled(ai === "true");
|
||||
if (key) setAiKey(key);
|
||||
if (aiUrl) setAiBaseUrl(aiUrl);
|
||||
if (aiMdl) setAiModel(aiMdl);
|
||||
if (titleTpl) setTitleTemplate(titleTpl);
|
||||
if (tpl) setMdTemplate(tpl);
|
||||
if (gp) setGitProvider(gp as GitProvider);
|
||||
if (gu) setGiteaUrl(gu);
|
||||
@@ -143,6 +267,9 @@ export default function SettingsScreen() {
|
||||
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
|
||||
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
|
||||
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
|
||||
SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl),
|
||||
SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel),
|
||||
SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate),
|
||||
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
|
||||
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
|
||||
SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl),
|
||||
@@ -161,117 +288,6 @@ export default function SettingsScreen() {
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: boolean;
|
||||
onValueChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
backgroundColor: Colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: Colors.text1, fontSize: 15 }}>{label}</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ true: Colors.primary, false: Colors.border }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentRow({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: { key: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text style={LABEL}>{label}</Text>
|
||||
<View style={{ flexDirection: "row", gap: 8 }}>
|
||||
{options.map((opt) => (
|
||||
<Pressable
|
||||
key={opt.key}
|
||||
onPress={() => onChange(opt.key)}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor:
|
||||
value === opt.key ? Colors.primary : Colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor:
|
||||
value === opt.key ? Colors.primary : Colors.border,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: value === opt.key ? Colors.bg : Colors.text2,
|
||||
fontWeight: "700",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
secure,
|
||||
url,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
secure?: boolean;
|
||||
url?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Text style={LABEL}>{label}</Text>
|
||||
<TextInput
|
||||
style={INPUT}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={Colors.textDim}
|
||||
secureTextEntry={secure}
|
||||
autoCapitalize="none"
|
||||
keyboardType={url ? "url" : "default"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: Colors.bg }}
|
||||
@@ -300,20 +316,76 @@ export default function SettingsScreen() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Text style={SECTION_TITLE}>AI (optional)</Text>
|
||||
<Field
|
||||
label="Title Template"
|
||||
value={titleTemplate}
|
||||
onChange={setTitleTemplate}
|
||||
placeholder="INC-001 (leave empty to disable)"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: Colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
marginTop: -8,
|
||||
marginBottom: 14,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: Colors.text2,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
|
||||
Pre-fills the Title field on new incidents. If the value ends with
|
||||
digits, the number is auto-incremented after each save.{"\n"}
|
||||
e.g. INC-001 → INC-002 → INC-003
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={SECTION_TITLE}>AI — Root Cause Assistant</Text>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: Colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 14,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: Colors.text2,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: Colors.text2, fontSize: 13, lineHeight: 20 }}>
|
||||
When enabled, a button appears on the capture form to suggest a root
|
||||
cause and fix based on the incident title and symptom. Uses any
|
||||
OpenAI-compatible API — set the base URL below to point at a local
|
||||
Ollama instance, or leave empty for OpenAI (api.openai.com).
|
||||
</Text>
|
||||
</View>
|
||||
<ToggleRow
|
||||
label="Enable AI assistance"
|
||||
value={aiEnabled}
|
||||
onValueChange={setAiEnabled}
|
||||
/>
|
||||
{aiEnabled && (
|
||||
<Field
|
||||
label="API Key"
|
||||
value={aiKey}
|
||||
onChange={setAiKey}
|
||||
placeholder="sk-..."
|
||||
secure
|
||||
/>
|
||||
<>
|
||||
<Field
|
||||
label="API Base URL"
|
||||
value={aiBaseUrl}
|
||||
onChange={setAiBaseUrl}
|
||||
placeholder="http://192.168.x.x:11434/v1 (empty = OpenAI)"
|
||||
url
|
||||
/>
|
||||
<Field
|
||||
label="Model"
|
||||
value={aiModel}
|
||||
onChange={setAiModel}
|
||||
placeholder="gpt-4o-mini or llama3.2"
|
||||
/>
|
||||
<Field
|
||||
label="API Key"
|
||||
value={aiKey}
|
||||
onChange={setAiKey}
|
||||
placeholder="sk-... (leave empty for Ollama)"
|
||||
secure
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={SECTION_TITLE}>Git Repository</Text>
|
||||
|
||||
Reference in New Issue
Block a user