Some checks failed
Auto Tag / auto-tag (push) Successful in 4s
Release / build-linux-arm64 (push) Failing after 1m7s
Test / rust-fmt-check (push) Successful in 1m11s
Release / build-macos-arm64 (push) Successful in 4m37s
Test / rust-clippy (push) Successful in 7m20s
Test / rust-tests (push) Successful in 8m5s
Test / frontend-typecheck (push) Successful in 1m22s
Test / frontend-tests (push) Successful in 1m16s
Release / build-linux-amd64 (push) Successful in 16m17s
Release / build-windows-amd64 (push) Successful in 13m5s
- Triage: move close intent check before the currentIssue guard so closing works even if the session hasn't fully initialized yet - Triage: save the user's close reason as a resolution step via addFiveWhyCmd before marking resolved, ensuring Resolution page is never empty - App: read version from Tauri getVersion() instead of hardcoded v0.1.1
253 lines
8.7 KiB
TypeScript
253 lines
8.7 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import { CheckCircle, ChevronRight } from "lucide-react";
|
|
import { open } from "@tauri-apps/plugin-dialog";
|
|
import { readTextFile } from "@tauri-apps/plugin-fs";
|
|
import { ChatWindow } from "@/components/ChatWindow";
|
|
import { TriageProgress } from "@/components/TriageProgress";
|
|
import { useSessionStore } from "@/stores/sessionStore";
|
|
import { useSettingsStore } from "@/stores/settingsStore";
|
|
import {
|
|
chatMessageCmd,
|
|
getIssueCmd,
|
|
getIssueMessagesCmd,
|
|
uploadLogFileCmd,
|
|
updateIssueCmd,
|
|
addFiveWhyCmd,
|
|
} from "@/lib/tauriCommands";
|
|
import type { TriageMessage } from "@/lib/tauriCommands";
|
|
|
|
const CLOSE_PATTERNS = [
|
|
"close this issue",
|
|
"please close",
|
|
"mark as resolved",
|
|
"mark resolved",
|
|
"issue is fixed",
|
|
"issue is resolved",
|
|
"resolve this",
|
|
"this is resolved",
|
|
];
|
|
|
|
function isCloseIntent(message: string): boolean {
|
|
const lower = message.toLowerCase();
|
|
return CLOSE_PATTERNS.some((p) => lower.includes(p));
|
|
}
|
|
|
|
type PendingFile = { name: string; content: string | null };
|
|
|
|
export default function Triage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
|
// Track the last user message so we can save it as a resolution step when why level advances
|
|
const lastUserMsgRef = useRef<string>("");
|
|
const initialized = useRef(false);
|
|
|
|
const { currentIssue, messages, currentWhyLevel, startSession, addMessage, setWhyLevel } =
|
|
useSessionStore();
|
|
const { getActiveProvider } = useSettingsStore();
|
|
|
|
useEffect(() => {
|
|
if (!id || initialized.current) return;
|
|
initialized.current = true;
|
|
|
|
Promise.all([getIssueCmd(id), getIssueMessagesCmd(id)])
|
|
.then(([detail, pastMessages]) => {
|
|
startSession(detail.issue);
|
|
|
|
if (pastMessages.length > 0) {
|
|
// Restore conversation history from DB
|
|
pastMessages.forEach((m, i) => {
|
|
addMessage({
|
|
id: `hist-${i}`,
|
|
issue_id: id,
|
|
role: m.role,
|
|
content: m.content,
|
|
why_level: 0,
|
|
created_at: Date.now() - (pastMessages.length - i) * 1000,
|
|
});
|
|
});
|
|
} else if (detail.resolution_steps.length === 0) {
|
|
// Fresh issue — show welcome prompt
|
|
addMessage({
|
|
id: "welcome",
|
|
issue_id: id,
|
|
role: "assistant",
|
|
content: `I'll guide you through a 5-Whys root cause analysis for: **"${detail.issue.title}"**\n\nDomain: **${detail.issue.category || "General"}**\n\nDescribe the symptoms you're observing — error messages, affected services, and when the issue started.`,
|
|
why_level: 0,
|
|
created_at: Date.now(),
|
|
});
|
|
}
|
|
})
|
|
.catch((e) => setError(String(e)));
|
|
}, [id]);
|
|
|
|
const handleAttach = async () => {
|
|
if (!id) return;
|
|
try {
|
|
const selected = await open({
|
|
multiple: true,
|
|
filters: [
|
|
{ name: "Log & Text Files", extensions: ["log", "txt", "json", "xml", "yaml", "yml", "csv", "out", "err"] },
|
|
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "bmp", "webp"] },
|
|
{ name: "All Files", extensions: ["*"] },
|
|
],
|
|
});
|
|
if (!selected) return;
|
|
const paths = Array.isArray(selected) ? selected : [selected];
|
|
for (const filePath of paths) {
|
|
const logFile = await uploadLogFileCmd(id, filePath);
|
|
let content: string | null = null;
|
|
try {
|
|
const raw = await readTextFile(filePath);
|
|
content = raw.slice(0, 8000); // cap at 8 KB to keep context manageable
|
|
} catch {
|
|
// Binary file (image) — include filename only as context
|
|
}
|
|
setPendingFiles((prev) => [...prev, { name: logFile.file_name, content }]);
|
|
}
|
|
} catch (e) {
|
|
setError(`Attachment failed: ${String(e)}`);
|
|
}
|
|
};
|
|
|
|
const handleSend = async (message: string) => {
|
|
if (!id) return;
|
|
|
|
// Close intent: works regardless of whether issue is fully loaded in session.
|
|
// Save the user's reason as a resolution step so the Resolution page is never empty.
|
|
if (isCloseIntent(message) && pendingFiles.length === 0) {
|
|
try {
|
|
await addFiveWhyCmd(id, 1, "Resolution", message, "Self-resolved by user");
|
|
await updateIssueCmd(id, { status: "resolved" });
|
|
navigate("/");
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!currentIssue) return;
|
|
|
|
const provider = getActiveProvider();
|
|
if (!provider) {
|
|
setError("No AI provider configured. Go to Settings > AI Providers.");
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
// Build AI context: user text + file contents; show only text + filenames in chat UI
|
|
const fileContext = pendingFiles
|
|
.map((f) =>
|
|
f.content
|
|
? `\n\n--- Attached: ${f.name} ---\n${f.content}`
|
|
: `\n\n[Image attached: ${f.name} — describe what you see if relevant]`
|
|
)
|
|
.join("");
|
|
const aiMessage = pendingFiles.length > 0 ? `${message}${fileContext}` : message;
|
|
const displayContent =
|
|
pendingFiles.length > 0
|
|
? `${message}${message ? "\n" : ""}📎 ${pendingFiles.map((f) => f.name).join(", ")}`
|
|
: message;
|
|
|
|
const userMsg: TriageMessage = {
|
|
id: `user-${Date.now()}`,
|
|
issue_id: id,
|
|
role: "user",
|
|
content: displayContent,
|
|
why_level: currentWhyLevel,
|
|
created_at: Date.now(),
|
|
};
|
|
lastUserMsgRef.current = message;
|
|
addMessage(userMsg);
|
|
setPendingFiles([]);
|
|
|
|
try {
|
|
const response = await chatMessageCmd(id, aiMessage, provider);
|
|
const assistantMsg: TriageMessage = {
|
|
id: `asst-${Date.now()}`,
|
|
issue_id: id,
|
|
role: "assistant",
|
|
content: response.content,
|
|
why_level: currentWhyLevel,
|
|
created_at: Date.now(),
|
|
};
|
|
addMessage(assistantMsg);
|
|
|
|
const lower = response.content.toLowerCase();
|
|
let nextLevel = currentWhyLevel;
|
|
if (lower.includes("why 2") || (currentWhyLevel === 1 && lower.includes("why is that"))) nextLevel = 2;
|
|
else if (lower.includes("why 3")) nextLevel = 3;
|
|
else if (lower.includes("why 4")) nextLevel = 4;
|
|
else if (lower.includes("why 5")) nextLevel = 5;
|
|
if (lower.includes("root cause") && (lower.includes("identified") || lower.includes("the root cause is"))) nextLevel = 6;
|
|
|
|
// Auto-save the completed why step as a resolution step
|
|
if (nextLevel > currentWhyLevel && currentWhyLevel >= 1 && currentWhyLevel <= 5) {
|
|
addFiveWhyCmd(
|
|
id,
|
|
currentWhyLevel,
|
|
`Why ${currentWhyLevel}: ${lastUserMsgRef.current}`,
|
|
response.content.slice(0, 500),
|
|
""
|
|
).catch(() => {}); // non-blocking, best-effort
|
|
}
|
|
if (nextLevel !== currentWhyLevel) setWhyLevel(nextLevel);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const canProceed = currentWhyLevel >= 5 || messages.some(
|
|
(m) => m.role === "assistant" && m.content.toLowerCase().includes("root cause")
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full" data-testid="triage-page">
|
|
<div className="border-b px-6 py-3 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="font-semibold">{currentIssue?.title ?? "Loading..."}</h1>
|
|
<p className="text-sm text-muted-foreground">5-Whys Root Cause Analysis</p>
|
|
</div>
|
|
<button
|
|
onClick={() => navigate(`/issue/${id}/resolution`)}
|
|
disabled={!canProceed}
|
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm disabled:opacity-50 hover:opacity-90"
|
|
>
|
|
<CheckCircle className="w-4 h-4" />
|
|
Resolution Steps
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-3 border-b">
|
|
<TriageProgress currentLevel={Math.min(currentWhyLevel, 5)} />
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mx-6 mt-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm text-destructive">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
<ChatWindow
|
|
messages={messages}
|
|
onSend={handleSend}
|
|
isLoading={isLoading}
|
|
placeholder="Describe the problem or answer the AI's question..."
|
|
pendingFiles={pendingFiles}
|
|
onAttach={handleAttach}
|
|
onRemoveFile={(i) => setPendingFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|