tftsr-devops_investigation/src/pages/Triage/index.tsx
Shaun Arman f04b5dfe06
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
fix: close from chat works before issue loads; save user reason as resolution step; dynamic version
- 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
2026-03-31 13:06:12 -05:00

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>
);
}