From 47af97b68e2818be727e803df810ec01fac11808 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Tue, 31 Mar 2026 12:50:39 -0500 Subject: [PATCH] feat: close issues, restore history, auto-save resolution steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db.rs: add get_issue_messages command (joins ai_conversations + ai_messages) - tauriCommands.ts: fix updateIssueCmd to pass updates as nested object (was spreading inline — Rust expects {issueId, updates}); fix addFiveWhyCmd parameter names to match Rust (stepOrder, whyQuestion, answer, evidence); add getIssueMessagesCmd and IssueMessage interface - Dashboard: X button on each open issue row to close (mark resolved) inline - Triage: restore conversation history from DB when revisiting existing issues; detect close intent patterns and mark issue resolved + navigate home; auto-save resolution step via addFiveWhyCmd when AI advances why level - tests: add issueActions.test.ts covering IPC arg structure and close intent --- src-tauri/src/commands/db.rs | 34 +++++++++++- src-tauri/src/lib.rs | 1 + src/lib/tauriCommands.ts | 23 +++++++- src/pages/Dashboard/index.tsx | 17 +++++- src/pages/Triage/index.tsx | 87 +++++++++++++++++++++++++---- tests/unit/issueActions.test.ts | 98 +++++++++++++++++++++++++++++++++ 6 files changed, 243 insertions(+), 17 deletions(-) create mode 100644 tests/unit/issueActions.test.ts diff --git a/src-tauri/src/commands/db.rs b/src-tauri/src/commands/db.rs index 7ab2bcde..2d3f1342 100644 --- a/src-tauri/src/commands/db.rs +++ b/src-tauri/src/commands/db.rs @@ -1,7 +1,7 @@ use tauri::State; use crate::db::models::{ - AiConversation, Issue, IssueDetail, IssueFilter, IssueSummary, IssueUpdate, LogFile, + AiConversation, AiMessage, Issue, IssueDetail, IssueFilter, IssueSummary, IssueUpdate, LogFile, ResolutionStep, }; use crate::state::AppState; @@ -361,6 +361,38 @@ pub async fn search_issues( list_issues(filter, state).await } +#[tauri::command] +pub async fn get_issue_messages( + issue_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let db = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = db + .prepare( + "SELECT am.id, am.conversation_id, am.role, am.content, am.token_count, am.created_at \ + FROM ai_messages am \ + JOIN ai_conversations ac ON ac.id = am.conversation_id \ + WHERE ac.issue_id = ?1 \ + ORDER BY am.created_at ASC", + ) + .map_err(|e| e.to_string())?; + let messages = stmt + .query_map([&issue_id], |row| { + Ok(AiMessage { + id: row.get(0)?, + conversation_id: row.get(1)?, + role: row.get(2)?, + content: row.get(3)?, + token_count: row.get(4)?, + created_at: row.get(5)?, + }) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + Ok(messages) +} + #[tauri::command] pub async fn add_five_why( issue_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a980edd9..c72c0b6d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -60,6 +60,7 @@ pub fn run() { commands::db::delete_issue, commands::db::list_issues, commands::db::search_issues, + commands::db::get_issue_messages, commands::db::add_five_why, commands::db::update_five_why, commands::db::add_timeline_event, diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index b2363b19..9a6b560d 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -285,7 +285,7 @@ export const listIssuesCmd = (query: IssueListQuery) => export const updateIssueCmd = ( issueId: string, updates: { title?: string; status?: string; severity?: string; description?: string; domain?: string } -) => invoke("update_issue", { issueId, ...updates }); +) => invoke("update_issue", { issueId, updates }); export const deleteIssueCmd = (issueId: string) => invoke("delete_issue", { issueId }); @@ -293,8 +293,25 @@ export const deleteIssueCmd = (issueId: string) => export const searchIssuesCmd = (query: string) => invoke("search_issues", { query }); -export const addFiveWhyCmd = (issueId: string, whyNumber: number, question: string, answer?: string) => - invoke("add_five_why", { issueId, whyNumber, question, answer }); +export interface IssueMessage { + id: string; + conversation_id: string; + role: string; + content: string; + token_count: number; + created_at: string; +} + +export const getIssueMessagesCmd = (issueId: string) => + invoke("get_issue_messages", { issueId }); + +export const addFiveWhyCmd = ( + issueId: string, + stepOrder: number, + whyQuestion: string, + answer: string, + evidence: string +) => invoke("add_five_why", { issueId, stepOrder, whyQuestion, answer, evidence }); export const updateFiveWhyCmd = (entryId: string, answer: string) => invoke("update_five_why", { entryId, answer }); diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index 5e96a63e..457ce16e 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -1,8 +1,9 @@ import React, { useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { Plus, AlertTriangle, CheckCircle, Clock, RefreshCw } from "lucide-react"; +import { Plus, AlertTriangle, CheckCircle, Clock, RefreshCw, X } from "lucide-react"; import { Card, CardHeader, CardTitle, CardContent, Badge, Button } from "@/components/ui"; import { useHistoryStore } from "@/stores/historyStore"; +import { updateIssueCmd } from "@/lib/tauriCommands"; export default function Dashboard() { const navigate = useNavigate(); @@ -132,6 +133,20 @@ export default function Dashboard() { {issue.status} + {issue.status !== "resolved" && ( + + )} ))} diff --git a/src/pages/Triage/index.tsx b/src/pages/Triage/index.tsx index 194a43e3..5d6aa54d 100644 --- a/src/pages/Triage/index.tsx +++ b/src/pages/Triage/index.tsx @@ -7,9 +7,32 @@ import { ChatWindow } from "@/components/ChatWindow"; import { TriageProgress } from "@/components/TriageProgress"; import { useSessionStore } from "@/stores/sessionStore"; import { useSettingsStore } from "@/stores/settingsStore"; -import { chatMessageCmd, getIssueCmd, uploadLogFileCmd } from "@/lib/tauriCommands"; +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() { @@ -18,6 +41,8 @@ export default function Triage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [pendingFiles, setPendingFiles] = useState([]); + // Track the last user message so we can save it as a resolution step when why level advances + const lastUserMsgRef = useRef(""); const initialized = useRef(false); const { currentIssue, messages, currentWhyLevel, startSession, addMessage, setWhyLevel } = @@ -28,20 +53,32 @@ export default function Triage() { if (!id || initialized.current) return; initialized.current = true; - getIssueCmd(id) - .then((detail) => { + Promise.all([getIssueCmd(id), getIssueMessagesCmd(id)]) + .then(([detail, pastMessages]) => { startSession(detail.issue); - if (detail.resolution_steps.length === 0) { - const welcome: TriageMessage = { + 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(), - }; - addMessage(welcome); + }); } }) .catch((e) => setError(String(e))); @@ -78,6 +115,18 @@ export default function Triage() { const handleSend = async (message: string) => { if (!id || !currentIssue) return; + + // Close intent: mark resolved and return to dashboard + if (isCloseIntent(message) && pendingFiles.length === 0) { + try { + await updateIssueCmd(id, { status: "resolved" }); + navigate("/"); + } catch (e) { + setError(String(e)); + } + return; + } + const provider = getActiveProvider(); if (!provider) { setError("No AI provider configured. Go to Settings > AI Providers."); @@ -109,6 +158,7 @@ export default function Triage() { why_level: currentWhyLevel, created_at: Date.now(), }; + lastUserMsgRef.current = message; addMessage(userMsg); setPendingFiles([]); @@ -125,11 +175,24 @@ export default function Triage() { addMessage(assistantMsg); const lower = response.content.toLowerCase(); - if (lower.includes("why 2") || (currentWhyLevel === 1 && lower.includes("why is that"))) setWhyLevel(2); - else if (lower.includes("why 3")) setWhyLevel(3); - else if (lower.includes("why 4")) setWhyLevel(4); - else if (lower.includes("why 5")) setWhyLevel(5); - if (lower.includes("root cause") && (lower.includes("identified") || lower.includes("the root cause is"))) setWhyLevel(6); + 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 { diff --git a/tests/unit/issueActions.test.ts b/tests/unit/issueActions.test.ts new file mode 100644 index 00000000..8142bab1 --- /dev/null +++ b/tests/unit/issueActions.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; + +const mockInvoke = vi.mocked(invoke); + +// Helper to capture the args passed to invoke +function lastInvokeArgs() { + const calls = mockInvoke.mock.calls; + return calls[calls.length - 1]; +} + +describe("updateIssueCmd IPC args", () => { + beforeEach(() => mockInvoke.mockReset()); + + it("passes updates as a nested object (not spread)", async () => { + mockInvoke.mockResolvedValueOnce({} as never); + const { updateIssueCmd } = await import("@/lib/tauriCommands"); + await updateIssueCmd("issue-1", { status: "resolved" }); + + const [cmd, args] = lastInvokeArgs(); + expect(cmd).toBe("update_issue"); + // args must have 'issueId' and 'updates' as separate keys + expect((args as Record).issueId).toBe("issue-1"); + expect((args as Record).updates).toEqual({ status: "resolved" }); + // must NOT have status at the top level + expect((args as Record).status).toBeUndefined(); + }); +}); + +describe("addFiveWhyCmd IPC args", () => { + beforeEach(() => mockInvoke.mockReset()); + + it("passes correct Rust parameter names", async () => { + mockInvoke.mockResolvedValueOnce({} as never); + const { addFiveWhyCmd } = await import("@/lib/tauriCommands"); + await addFiveWhyCmd("issue-1", 1, "Why did it fail?", "Network timeout", ""); + + const [cmd, args] = lastInvokeArgs(); + expect(cmd).toBe("add_five_why"); + const a = args as Record; + expect(a.issueId).toBe("issue-1"); + expect(a.stepOrder).toBe(1); + expect(a.whyQuestion).toBe("Why did it fail?"); + expect(a.answer).toBe("Network timeout"); + expect(a.evidence).toBe(""); + }); +}); + +describe("getIssueMessagesCmd IPC args", () => { + beforeEach(() => mockInvoke.mockReset()); + + it("calls get_issue_messages with issueId", async () => { + mockInvoke.mockResolvedValueOnce([]); + const { getIssueMessagesCmd } = await import("@/lib/tauriCommands"); + await getIssueMessagesCmd("issue-1"); + + const [cmd, args] = lastInvokeArgs(); + expect(cmd).toBe("get_issue_messages"); + expect((args as Record).issueId).toBe("issue-1"); + }); +}); + +describe("close intent detection", () => { + const closePatterns = [ + "close this issue", + "please close", + "mark as resolved", + "mark resolved", + "issue is fixed", + "issue is resolved", + "resolve this", + "this is resolved", + ]; + + const notCloseMessages = [ + "why did the server close the connection", + "the issue resolves around DNS", + "how do I fix this", + ]; + + function isCloseIntent(message: string): boolean { + const lower = message.toLowerCase(); + return closePatterns.some((p) => lower.includes(p)); + } + + it("detects close intent patterns", () => { + expect(isCloseIntent("Please close this issue as it was a test")).toBe(true); + expect(isCloseIntent("Mark as resolved")).toBe(true); + expect(isCloseIntent("This issue is fixed now")).toBe(true); + expect(isCloseIntent("issue is resolved")).toBe(true); + }); + + it("does not flag non-close messages as close intent", () => { + for (const msg of notCloseMessages) { + expect(isCloseIntent(msg)).toBe(false); + } + }); +});