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
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:
parent
2f2becd4f2
commit
47af97b68e
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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 {
|
||||
|
||||
98
tests/unit/issueActions.test.ts
Normal file
98
tests/unit/issueActions.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user