feat: close issues, restore history, auto-save resolution steps
Some checks failed
Test / frontend-typecheck (push) Waiting to run
Test / frontend-tests (push) Waiting to run
Auto Tag / auto-tag (push) Successful in 4s
Test / rust-fmt-check (push) Successful in 1m2s
Release / build-linux-arm64 (push) Failing after 1m11s
Release / build-macos-arm64 (push) Successful in 4m31s
Test / rust-clippy (push) Successful in 7m44s
Test / rust-tests (push) Has been cancelled
Release / build-linux-amd64 (push) Successful in 16m6s
Release / build-windows-amd64 (push) Successful in 12m38s

- 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
This commit is contained in:
Shaun Arman 2026-03-31 12:50:39 -05:00
parent 2f2becd4f2
commit 47af97b68e
6 changed files with 243 additions and 17 deletions

View File

@ -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<Vec<AiMessage>, 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,

View File

@ -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,

View File

@ -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<IssueDetail>("update_issue", { issueId, ...updates });
) => invoke<Issue>("update_issue", { issueId, updates });
export const deleteIssueCmd = (issueId: string) =>
invoke<void>("delete_issue", { issueId });
@ -293,8 +293,25 @@ export const deleteIssueCmd = (issueId: string) =>
export const searchIssuesCmd = (query: string) =>
invoke<IssueSummary[]>("search_issues", { query });
export const addFiveWhyCmd = (issueId: string, whyNumber: number, question: string, answer?: string) =>
invoke<FiveWhyEntry>("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<IssueMessage[]>("get_issue_messages", { issueId });
export const addFiveWhyCmd = (
issueId: string,
stepOrder: number,
whyQuestion: string,
answer: string,
evidence: string
) => invoke<ResolutionStep>("add_five_why", { issueId, stepOrder, whyQuestion, answer, evidence });
export const updateFiveWhyCmd = (entryId: string, answer: string) =>
invoke<void>("update_five_why", { entryId, answer });

View File

@ -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() {
<Badge variant={statusVariant(issue.status)}>
{issue.status}
</Badge>
{issue.status !== "resolved" && (
<button
title="Close issue"
onClick={(e) => {
e.stopPropagation();
updateIssueCmd(issue.id, { status: "resolved" }).then(() =>
loadIssues()
);
}}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}

View File

@ -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<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 } =
@ -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 {

View File

@ -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<string, unknown>).issueId).toBe("issue-1");
expect((args as Record<string, unknown>).updates).toEqual({ status: "resolved" });
// must NOT have status at the top level
expect((args as Record<string, unknown>).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<string, unknown>;
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<string, unknown>).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);
}
});
});