diff --git a/src/components/Draft/DraftTab.tsx b/src/components/Draft/DraftTab.tsx index 9717f8f..1d78034 100644 --- a/src/components/Draft/DraftTab.tsx +++ b/src/components/Draft/DraftTab.tsx @@ -14,6 +14,10 @@ import { ResponsePanel } from "./ResponsePanel"; import { AlternativePanel } from "./AlternativePanel"; import { SavedResponsesSuggestion } from "./SavedResponsesSuggestion"; import { ConversationThread, ConversationEntry } from "./ConversationThread"; +import { useDraftApproval } from "./useDraftApproval"; +import { useDraftChecklist } from "./useDraftChecklist"; +import { useDraftFirstResponse } from "./useDraftFirstResponse"; +import { useDraftIntake } from "./useDraftIntake"; import { ConversationInput } from "./ConversationInput"; import { WorkspaceDialogs } from "./WorkspaceDialogs"; import { WorkspaceModeShell } from "./WorkspaceModeShell"; @@ -38,7 +42,6 @@ import { useWorkspaceCommandBridge } from "../../features/workspace/useWorkspace import { useWorkspaceDraftState } from "../../features/workspace/useWorkspaceDraftState"; import { applyResolutionKit, - analyzeCaseIntake, buildResolutionKitFromWorkspace, buildSimilarCases, compactLines, @@ -57,19 +60,14 @@ import { } from "../../features/analytics/qualityMetrics"; import type { JiraTicket } from "../../hooks/useJira"; import type { - ChecklistState, ConfidenceAssessment, GenerationMetrics, GroundedClaim, - ChecklistItem, - FirstResponseTone, } from "../../types/llm"; -import type { ContextSource, SearchResult } from "../../types/knowledge"; +import type { ContextSource } from "../../types/knowledge"; import type { - CaseIntake, GuidedRunbookTemplate, NextActionRecommendation, - NoteAudience, ResolutionKit, ResponseLength, SavedDraft, @@ -205,32 +203,6 @@ function isEditableTarget(target: EventTarget | null): boolean { ); } -const INTAKE_PRESETS: Record< - "incident" | "access" | "rollout" | "device", - Partial -> = { - incident: { - likely_category: "incident", - urgency: "high", - note_audience: "internal-note", - }, - access: { - likely_category: "access", - urgency: "normal", - note_audience: "internal-note", - }, - rollout: { - likely_category: "change-rollout", - urgency: "normal", - note_audience: "internal-note", - }, - device: { - likely_category: "device-environment", - urgency: "normal", - note_audience: "internal-note", - }, -}; - export const DraftTab = forwardRef( function DraftTab( { initialDraft, onNavigateToSource, revampModeEnabled = false }, @@ -293,29 +265,33 @@ export const DraftTab = forwardRef( const modelLoaded = appStatus.llmLoaded; const loadedModelName = appStatus.llmModelName; + const { + approvalQuery, + setApprovalQuery, + approvalResults, + setApprovalResults, + approvalSearching, + approvalSummary, + setApprovalSummary, + approvalSummarizing, + approvalSources, + setApprovalSources, + approvalError, + setApprovalError, + handleApprovalSearch, + handleApprovalSummarize, + resetApproval, + } = useDraftApproval({ + searchKb, + generateWithContextParams, + modelLoaded, + onShowError: showError, + }); + const [input, setInput] = useState(""); const [ocrText, setOcrText] = useState(null); const [diagnosticNotes, setDiagnosticNotes] = useState(""); const [treeResult, setTreeResult] = useState(null); - const [checklistItems, setChecklistItems] = useState([]); - const [checklistCompleted, setChecklistCompleted] = useState< - Record - >({}); - const [checklistGenerating, setChecklistGenerating] = useState(false); - const [checklistUpdating, setChecklistUpdating] = useState(false); - const [checklistError, setChecklistError] = useState(null); - const [firstResponse, setFirstResponse] = useState(""); - const [firstResponseTone, setFirstResponseTone] = - useState("slack"); - const [firstResponseGenerating, setFirstResponseGenerating] = - useState(false); - const [approvalQuery, setApprovalQuery] = useState(""); - const [approvalResults, setApprovalResults] = useState([]); - const [approvalSearching, setApprovalSearching] = useState(false); - const [approvalSummary, setApprovalSummary] = useState(""); - const [approvalSummarizing, setApprovalSummarizing] = useState(false); - const [approvalSources, setApprovalSources] = useState([]); - const [approvalError, setApprovalError] = useState(null); const [response, setResponse] = useState(""); const [sources, setSources] = useState([]); const [metrics, setMetrics] = useState(null); @@ -361,10 +337,6 @@ export const DraftTab = forwardRef( ConversationEntry[] >([]); const [handoffTouched, setHandoffTouched] = useState(false); - const [caseIntake, setCaseIntake] = useState(() => ({ - ...parseCaseIntake(null), - note_audience: loadWorkspacePersonalization().preferred_note_audience, - })); const [similarCases, setSimilarCases] = useState([]); const [similarCasesLoading, setSimilarCasesLoading] = useState(false); const [compareCase, setCompareCase] = useState(null); @@ -390,6 +362,52 @@ export const DraftTab = forwardRef( const [suggestionsDismissed, setSuggestionsDismissed] = useState(false); const firstDraftStartMsRef = useRef(null); + const { + firstResponse, + setFirstResponse, + firstResponseTone, + setFirstResponseTone, + firstResponseGenerating, + handleGenerateFirstResponse, + handleCopyFirstResponse, + handleClearFirstResponse, + resetFirstResponse, + } = useDraftFirstResponse({ + input, + ocrText, + currentTicket, + modelLoaded, + generateFirstResponse, + onShowSuccess: showSuccess, + onShowError: showError, + }); + + const { + checklistItems, + setChecklistItems, + checklistCompleted, + setChecklistCompleted, + checklistGenerating, + checklistUpdating, + checklistError, + setChecklistError, + handleChecklistGenerate, + handleChecklistUpdate, + handleChecklistToggle, + handleChecklistClear, + resetChecklist, + } = useDraftChecklist({ + input, + ocrText, + diagnosticNotes, + treeResult, + currentTicket, + modelLoaded, + generateChecklist, + updateChecklist, + onShowError: showError, + }); + const { resolutionKits, workspaceFavorites, @@ -421,52 +439,22 @@ export const DraftTab = forwardRef( void refreshWorkspaceCatalog(); }, [refreshWorkspaceCatalog]); - const handleIntakeFieldChange = useCallback( - (field: keyof CaseIntake, value: string) => { - setCaseIntake((prev) => ({ - ...prev, - [field]: value, - })); - }, - [], - ); - - const handleAnalyzeIntake = useCallback(() => { - setCaseIntake((prev) => - analyzeCaseIntake(input, currentTicket ?? undefined, prev), - ); - void logEvent("workspace_intake_analyzed", { - ticket_id: currentTicketId, - has_ticket: Boolean(currentTicketId), - has_response: Boolean(response.trim()), - }); - }, [input, currentTicket, logEvent, currentTicketId, response]); - - const handleApplyIntakePreset = useCallback( - (preset: "incident" | "access" | "rollout" | "device") => { - setCaseIntake((prev) => ({ - ...prev, - ...INTAKE_PRESETS[preset], - })); - void logEvent("workspace_intake_preset_applied", { preset }); - }, - [logEvent], - ); - - const handleNoteAudienceChange = useCallback( - (audience: NoteAudience) => { - setCaseIntake((prev) => ({ - ...prev, - note_audience: audience, - })); - setWorkspacePersonalization((prev) => ({ - ...prev, - preferred_note_audience: audience, - })); - void logEvent("workspace_note_audience_changed", { audience }); - }, - [logEvent], - ); + const { + caseIntake, + setCaseIntake, + handleIntakeFieldChange, + handleAnalyzeIntake, + handleApplyIntakePreset, + handleNoteAudienceChange, + } = useDraftIntake({ + initialNoteAudience: workspacePersonalization.preferred_note_audience, + input, + currentTicket, + currentTicketId, + response, + logEvent, + setWorkspacePersonalization, + }); const handleResponseLengthChange = useCallback((length: ResponseLength) => { setResponseLength(length); @@ -658,263 +646,6 @@ export const DraftTab = forwardRef( currentTicketId, ]); - const handleGenerateFirstResponse = useCallback(async () => { - if (firstResponseGenerating) return; - - if (!modelLoaded) { - showError("No model loaded. Go to Settings to load a model."); - return; - } - - const ticketFallback = currentTicket - ? `${currentTicket.summary}${currentTicket.description ? `\n\n${currentTicket.description}` : ""}` - : ""; - const promptInput = - input.trim() || ticketFallback.trim() || ocrText?.trim() || ""; - if (!promptInput) { - showError( - "Add ticket details or notes before generating a first response.", - ); - return; - } - - setFirstResponseGenerating(true); - try { - const result = await generateFirstResponse({ - user_input: promptInput, - tone: firstResponseTone, - ocr_text: ocrText ?? undefined, - jira_ticket: currentTicket ?? undefined, - }); - setFirstResponse(result.text); - } catch (e) { - console.error("First response generation failed:", e); - showError(`First response failed: ${e}`); - } finally { - setFirstResponseGenerating(false); - } - }, [ - input, - firstResponseGenerating, - modelLoaded, - generateFirstResponse, - firstResponseTone, - ocrText, - currentTicket, - showError, - ]); - - const handleCopyFirstResponse = useCallback(async () => { - if (!firstResponse.trim()) return; - try { - await navigator.clipboard.writeText(firstResponse); - showSuccess("First response copied to clipboard"); - } catch { - showError("Failed to copy first response"); - } - }, [firstResponse, showSuccess, showError]); - - const handleClearFirstResponse = useCallback(() => { - setFirstResponse(""); - }, []); - - const handleChecklistGenerate = useCallback(async () => { - if (checklistGenerating) return; - - if (!modelLoaded) { - showError("No model loaded. Go to Settings to load a model."); - return; - } - - const ticketFallback = currentTicket - ? `${currentTicket.summary}${currentTicket.description ? `\n\n${currentTicket.description}` : ""}` - : ""; - const promptInput = - input.trim() || ticketFallback.trim() || ocrText?.trim() || ""; - if (!promptInput) { - setChecklistError( - "Add ticket details or notes before generating a checklist.", - ); - return; - } - - setChecklistGenerating(true); - setChecklistError(null); - try { - const treeDecisions = treeResult - ? { - tree_name: treeResult.treeName, - path_summary: treeResult.pathSummary, - } - : undefined; - - const result = await generateChecklist({ - user_input: promptInput, - ocr_text: ocrText ?? undefined, - diagnostic_notes: diagnosticNotes || undefined, - tree_decisions: treeDecisions, - jira_ticket: currentTicket ?? undefined, - }); - - setChecklistItems(result.items); - setChecklistCompleted({}); - } catch (e) { - console.error("Checklist generation failed:", e); - setChecklistError(`Checklist failed: ${e}`); - } finally { - setChecklistGenerating(false); - } - }, [ - input, - checklistGenerating, - modelLoaded, - treeResult, - ocrText, - diagnosticNotes, - currentTicket, - generateChecklist, - showError, - ]); - - const handleChecklistUpdate = useCallback(async () => { - if (!checklistItems.length || checklistUpdating) return; - - if (!modelLoaded) { - showError("No model loaded. Go to Settings to load a model."); - return; - } - - const ticketFallback = currentTicket - ? `${currentTicket.summary}${currentTicket.description ? `\n\n${currentTicket.description}` : ""}` - : ""; - const promptInput = - input.trim() || ticketFallback.trim() || ocrText?.trim() || ""; - if (!promptInput) { - setChecklistError( - "Add ticket details or notes before updating the checklist.", - ); - return; - } - - setChecklistUpdating(true); - setChecklistError(null); - try { - const treeDecisions = treeResult - ? { - tree_name: treeResult.treeName, - path_summary: treeResult.pathSummary, - } - : undefined; - - const completedIds = Object.keys(checklistCompleted).filter( - (id) => checklistCompleted[id], - ); - const checklist: ChecklistState = { - items: checklistItems, - completed_ids: completedIds, - }; - - const result = await updateChecklist({ - user_input: promptInput, - ocr_text: ocrText ?? undefined, - diagnostic_notes: diagnosticNotes || undefined, - tree_decisions: treeDecisions, - jira_ticket: currentTicket ?? undefined, - checklist, - }); - - const updatedCompleted: Record = {}; - for (const item of result.items) { - if (checklistCompleted[item.id]) { - updatedCompleted[item.id] = true; - } - } - - setChecklistItems(result.items); - setChecklistCompleted(updatedCompleted); - } catch (e) { - console.error("Checklist update failed:", e); - setChecklistError(`Checklist update failed: ${e}`); - } finally { - setChecklistUpdating(false); - } - }, [ - checklistItems, - checklistUpdating, - modelLoaded, - input, - ocrText, - diagnosticNotes, - treeResult, - currentTicket, - checklistCompleted, - updateChecklist, - showError, - ]); - - const handleChecklistToggle = useCallback((id: string) => { - setChecklistCompleted((prev) => ({ - ...prev, - [id]: !prev[id], - })); - }, []); - - const handleChecklistClear = useCallback(() => { - setChecklistItems([]); - setChecklistCompleted({}); - setChecklistError(null); - }, []); - - const handleApprovalSearch = useCallback(async () => { - if (!approvalQuery.trim()) { - setApprovalError("Enter a search term to look up approvals."); - return; - } - - setApprovalSearching(true); - setApprovalError(null); - try { - const results = await searchKb(approvalQuery.trim(), 5); - setApprovalResults(results); - } catch (e) { - console.error("Approval search failed:", e); - setApprovalError("Approval search failed."); - } finally { - setApprovalSearching(false); - } - }, [approvalQuery, searchKb]); - - const handleApprovalSummarize = useCallback(async () => { - if (!approvalQuery.trim()) { - setApprovalError("Enter a search term to summarize approvals."); - return; - } - - if (!modelLoaded) { - showError("No model loaded. Go to Settings to load a model."); - return; - } - - setApprovalSummarizing(true); - setApprovalError(null); - try { - const prompt = `Summarize the approval steps and owner(s) for: ${approvalQuery.trim()}. Keep it concise. If sources do not mention it, say so.`; - const result = await generateWithContextParams({ - user_input: prompt, - kb_limit: 5, - response_length: "Short", - }); - - setApprovalSummary(result.text); - setApprovalSources(result.sources); - } catch (e) { - console.error("Approval summary failed:", e); - setApprovalError("Approval summary failed."); - } finally { - setApprovalSummarizing(false); - } - }, [approvalQuery, modelLoaded, generateWithContextParams, showError]); - const handleApplyTemplate = useCallback((content: string) => { setResponse(content); }, []); @@ -1069,21 +800,9 @@ export const DraftTab = forwardRef( setOcrText(null); setDiagnosticNotes(""); setTreeResult(null); - setChecklistItems([]); - setChecklistCompleted({}); - setChecklistError(null); - setChecklistGenerating(false); - setChecklistUpdating(false); - setFirstResponse(""); - setFirstResponseTone("slack"); - setFirstResponseGenerating(false); - setApprovalQuery(""); - setApprovalResults([]); - setApprovalSummary(""); - setApprovalSources([]); - setApprovalError(null); - setApprovalSearching(false); - setApprovalSummarizing(false); + resetChecklist(); + resetFirstResponse(); + resetApproval(); setResponse(""); setOriginalResponse(""); setIsResponseEdited(false); @@ -2195,15 +1914,6 @@ export const DraftTab = forwardRef( loadTemplates(); }, [loadTemplates]); - useEffect(() => { - if (!approvalQuery.trim()) { - setApprovalResults([]); - setApprovalSummary(""); - setApprovalSources([]); - setApprovalError(null); - } - }, [approvalQuery]); - const handleCopyResponse = useCallback(async () => { if (!response) return; try { diff --git a/src/components/Draft/useDraftApproval.test.ts b/src/components/Draft/useDraftApproval.test.ts new file mode 100644 index 0000000..dafb00f --- /dev/null +++ b/src/components/Draft/useDraftApproval.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useDraftApproval } from "./useDraftApproval"; + +function makeOptions( + overrides: Partial[0]> = {}, +) { + return { + searchKb: vi.fn().mockResolvedValue([]), + generateWithContextParams: vi + .fn() + .mockResolvedValue({ text: "summary", sources: [] }), + modelLoaded: true, + onShowError: vi.fn(), + ...overrides, + }; +} + +describe("useDraftApproval", () => { + it("sets an error when searching with an empty query", async () => { + const options = makeOptions(); + const { result } = renderHook(() => useDraftApproval(options)); + + await act(async () => { + await result.current.handleApprovalSearch(); + }); + + expect(result.current.approvalError).toMatch(/enter a search term/i); + expect(options.searchKb).not.toHaveBeenCalled(); + }); + + it("stores results from a successful approval search", async () => { + const searchKb = vi + .fn() + .mockResolvedValue([ + { id: "kb-1", title: "Approval Policy", snippet: "..." }, + ]); + const options = makeOptions({ searchKb }); + const { result } = renderHook(() => useDraftApproval(options)); + + act(() => { + result.current.setApprovalQuery("password reset"); + }); + + await act(async () => { + await result.current.handleApprovalSearch(); + }); + + expect(searchKb).toHaveBeenCalledWith("password reset", 5); + expect(result.current.approvalResults).toHaveLength(1); + expect(result.current.approvalError).toBeNull(); + }); + + it("blocks summarize when no model is loaded and surfaces a toast error", async () => { + const onShowError = vi.fn(); + const options = makeOptions({ modelLoaded: false, onShowError }); + const { result } = renderHook(() => useDraftApproval(options)); + + act(() => { + result.current.setApprovalQuery("vpn access"); + }); + + await act(async () => { + await result.current.handleApprovalSummarize(); + }); + + expect(onShowError).toHaveBeenCalledWith( + expect.stringContaining("No model loaded"), + ); + expect(options.generateWithContextParams).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Draft/useDraftApproval.ts b/src/components/Draft/useDraftApproval.ts new file mode 100644 index 0000000..52de14a --- /dev/null +++ b/src/components/Draft/useDraftApproval.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useState } from "react"; +import type { ContextSource, SearchResult } from "../../types/knowledge"; + +interface UseDraftApprovalOptions { + searchKb: (query: string, limit: number) => Promise; + generateWithContextParams: (params: { + user_input: string; + kb_limit: number; + response_length: "Short" | "Medium" | "Long"; + }) => Promise<{ text: string; sources: ContextSource[] }>; + modelLoaded: boolean; + onShowError: (message: string) => void; +} + +export function useDraftApproval({ + searchKb, + generateWithContextParams, + modelLoaded, + onShowError, +}: UseDraftApprovalOptions) { + const [approvalQuery, setApprovalQuery] = useState(""); + const [approvalResults, setApprovalResults] = useState([]); + const [approvalSearching, setApprovalSearching] = useState(false); + const [approvalSummary, setApprovalSummary] = useState(""); + const [approvalSummarizing, setApprovalSummarizing] = useState(false); + const [approvalSources, setApprovalSources] = useState([]); + const [approvalError, setApprovalError] = useState(null); + + useEffect(() => { + if (!approvalQuery.trim()) { + setApprovalResults([]); + setApprovalSummary(""); + setApprovalSources([]); + setApprovalError(null); + } + }, [approvalQuery]); + + const handleApprovalSearch = useCallback(async () => { + if (!approvalQuery.trim()) { + setApprovalError("Enter a search term to look up approvals."); + return; + } + + setApprovalSearching(true); + setApprovalError(null); + try { + const results = await searchKb(approvalQuery.trim(), 5); + setApprovalResults(results); + } catch (e) { + console.error("Approval search failed:", e); + setApprovalError("Approval search failed."); + } finally { + setApprovalSearching(false); + } + }, [approvalQuery, searchKb]); + + const handleApprovalSummarize = useCallback(async () => { + if (!approvalQuery.trim()) { + setApprovalError("Enter a search term to summarize approvals."); + return; + } + + if (!modelLoaded) { + onShowError("No model loaded. Go to Settings to load a model."); + return; + } + + setApprovalSummarizing(true); + setApprovalError(null); + try { + const prompt = `Summarize the approval steps and owner(s) for: ${approvalQuery.trim()}. Keep it concise. If sources do not mention it, say so.`; + const result = await generateWithContextParams({ + user_input: prompt, + kb_limit: 5, + response_length: "Short", + }); + + setApprovalSummary(result.text); + setApprovalSources(result.sources); + } catch (e) { + console.error("Approval summary failed:", e); + setApprovalError("Approval summary failed."); + } finally { + setApprovalSummarizing(false); + } + }, [approvalQuery, modelLoaded, generateWithContextParams, onShowError]); + + const resetApproval = useCallback(() => { + setApprovalQuery(""); + setApprovalResults([]); + setApprovalSummary(""); + setApprovalSources([]); + setApprovalError(null); + setApprovalSearching(false); + setApprovalSummarizing(false); + }, []); + + return { + approvalQuery, + setApprovalQuery, + approvalResults, + setApprovalResults, + approvalSearching, + approvalSummary, + setApprovalSummary, + approvalSummarizing, + approvalSources, + setApprovalSources, + approvalError, + setApprovalError, + handleApprovalSearch, + handleApprovalSummarize, + resetApproval, + }; +} diff --git a/src/components/Draft/useDraftChecklist.test.ts b/src/components/Draft/useDraftChecklist.test.ts new file mode 100644 index 0000000..e7250d8 --- /dev/null +++ b/src/components/Draft/useDraftChecklist.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useDraftChecklist } from "./useDraftChecklist"; + +function makeOptions( + overrides: Partial[0]> = {}, +) { + return { + input: "user vpn disconnects", + ocrText: null, + diagnosticNotes: "", + treeResult: null, + currentTicket: null, + modelLoaded: true, + generateChecklist: vi + .fn() + .mockResolvedValue({ items: [{ id: "a", label: "Ping gateway" }] }), + updateChecklist: vi + .fn() + .mockResolvedValue({ items: [{ id: "a", label: "Ping gateway" }] }), + onShowError: vi.fn(), + ...overrides, + }; +} + +describe("useDraftChecklist", () => { + it("generates a checklist and stores items", async () => { + const options = makeOptions(); + const { result } = renderHook(() => useDraftChecklist(options)); + + await act(async () => { + await result.current.handleChecklistGenerate(); + }); + + expect(options.generateChecklist).toHaveBeenCalledWith( + expect.objectContaining({ user_input: "user vpn disconnects" }), + ); + expect(result.current.checklistItems).toHaveLength(1); + expect(result.current.checklistError).toBeNull(); + }); + + it("sets a local error when prompt input is empty", async () => { + const options = makeOptions({ + input: "", + ocrText: null, + currentTicket: null, + }); + const { result } = renderHook(() => useDraftChecklist(options)); + + await act(async () => { + await result.current.handleChecklistGenerate(); + }); + + expect(result.current.checklistError).toMatch( + /add ticket details or notes/i, + ); + expect(options.generateChecklist).not.toHaveBeenCalled(); + }); + + it("toggles completion state per id", () => { + const { result } = renderHook(() => useDraftChecklist(makeOptions())); + + act(() => { + result.current.handleChecklistToggle("a"); + }); + expect(result.current.checklistCompleted).toEqual({ a: true }); + + act(() => { + result.current.handleChecklistToggle("a"); + }); + expect(result.current.checklistCompleted).toEqual({ a: false }); + }); +}); diff --git a/src/components/Draft/useDraftChecklist.ts b/src/components/Draft/useDraftChecklist.ts new file mode 100644 index 0000000..069dd81 --- /dev/null +++ b/src/components/Draft/useDraftChecklist.ts @@ -0,0 +1,218 @@ +import { useCallback, useState } from "react"; +import type { TreeResult } from "./DiagnosisPanel"; +import type { JiraTicket } from "../../hooks/useJira"; +import type { ChecklistItem, ChecklistState } from "../../types/llm"; + +interface ChecklistRequestParams { + user_input: string; + ocr_text?: string; + diagnostic_notes?: string; + tree_decisions?: { tree_name: string; path_summary: string }; + jira_ticket?: JiraTicket; +} + +interface UseDraftChecklistOptions { + input: string; + ocrText: string | null; + diagnosticNotes: string; + treeResult: TreeResult | null; + currentTicket: JiraTicket | null; + modelLoaded: boolean; + generateChecklist: ( + params: ChecklistRequestParams, + ) => Promise<{ items: ChecklistItem[] }>; + updateChecklist: ( + params: ChecklistRequestParams & { checklist: ChecklistState }, + ) => Promise<{ items: ChecklistItem[] }>; + onShowError: (message: string) => void; +} + +export function useDraftChecklist({ + input, + ocrText, + diagnosticNotes, + treeResult, + currentTicket, + modelLoaded, + generateChecklist, + updateChecklist, + onShowError, +}: UseDraftChecklistOptions) { + const [checklistItems, setChecklistItems] = useState([]); + const [checklistCompleted, setChecklistCompleted] = useState< + Record + >({}); + const [checklistGenerating, setChecklistGenerating] = useState(false); + const [checklistUpdating, setChecklistUpdating] = useState(false); + const [checklistError, setChecklistError] = useState(null); + + const buildPromptInput = useCallback(() => { + const ticketFallback = currentTicket + ? `${currentTicket.summary}${currentTicket.description ? `\n\n${currentTicket.description}` : ""}` + : ""; + return input.trim() || ticketFallback.trim() || ocrText?.trim() || ""; + }, [currentTicket, input, ocrText]); + + const handleChecklistGenerate = useCallback(async () => { + if (checklistGenerating) return; + + if (!modelLoaded) { + onShowError("No model loaded. Go to Settings to load a model."); + return; + } + + const promptInput = buildPromptInput(); + if (!promptInput) { + setChecklistError( + "Add ticket details or notes before generating a checklist.", + ); + return; + } + + setChecklistGenerating(true); + setChecklistError(null); + try { + const treeDecisions = treeResult + ? { + tree_name: treeResult.treeName, + path_summary: treeResult.pathSummary, + } + : undefined; + + const result = await generateChecklist({ + user_input: promptInput, + ocr_text: ocrText ?? undefined, + diagnostic_notes: diagnosticNotes || undefined, + tree_decisions: treeDecisions, + jira_ticket: currentTicket ?? undefined, + }); + + setChecklistItems(result.items); + setChecklistCompleted({}); + } catch (e) { + console.error("Checklist generation failed:", e); + setChecklistError(`Checklist failed: ${e}`); + } finally { + setChecklistGenerating(false); + } + }, [ + buildPromptInput, + checklistGenerating, + modelLoaded, + treeResult, + ocrText, + diagnosticNotes, + currentTicket, + generateChecklist, + onShowError, + ]); + + const handleChecklistUpdate = useCallback(async () => { + if (!checklistItems.length || checklistUpdating) return; + + if (!modelLoaded) { + onShowError("No model loaded. Go to Settings to load a model."); + return; + } + + const promptInput = buildPromptInput(); + if (!promptInput) { + setChecklistError( + "Add ticket details or notes before updating the checklist.", + ); + return; + } + + setChecklistUpdating(true); + setChecklistError(null); + try { + const treeDecisions = treeResult + ? { + tree_name: treeResult.treeName, + path_summary: treeResult.pathSummary, + } + : undefined; + + const completedIds = Object.keys(checklistCompleted).filter( + (id) => checklistCompleted[id], + ); + const checklist: ChecklistState = { + items: checklistItems, + completed_ids: completedIds, + }; + + const result = await updateChecklist({ + user_input: promptInput, + ocr_text: ocrText ?? undefined, + diagnostic_notes: diagnosticNotes || undefined, + tree_decisions: treeDecisions, + jira_ticket: currentTicket ?? undefined, + checklist, + }); + + const updatedCompleted: Record = {}; + for (const item of result.items) { + if (checklistCompleted[item.id]) { + updatedCompleted[item.id] = true; + } + } + + setChecklistItems(result.items); + setChecklistCompleted(updatedCompleted); + } catch (e) { + console.error("Checklist update failed:", e); + setChecklistError(`Checklist update failed: ${e}`); + } finally { + setChecklistUpdating(false); + } + }, [ + buildPromptInput, + checklistItems, + checklistUpdating, + modelLoaded, + ocrText, + diagnosticNotes, + treeResult, + currentTicket, + checklistCompleted, + updateChecklist, + onShowError, + ]); + + const handleChecklistToggle = useCallback((id: string) => { + setChecklistCompleted((prev) => ({ + ...prev, + [id]: !prev[id], + })); + }, []); + + const handleChecklistClear = useCallback(() => { + setChecklistItems([]); + setChecklistCompleted({}); + setChecklistError(null); + }, []); + + const resetChecklist = useCallback(() => { + setChecklistItems([]); + setChecklistCompleted({}); + setChecklistError(null); + setChecklistGenerating(false); + setChecklistUpdating(false); + }, []); + + return { + checklistItems, + setChecklistItems, + checklistCompleted, + setChecklistCompleted, + checklistGenerating, + checklistUpdating, + checklistError, + setChecklistError, + handleChecklistGenerate, + handleChecklistUpdate, + handleChecklistToggle, + handleChecklistClear, + resetChecklist, + }; +} diff --git a/src/components/Draft/useDraftFirstResponse.test.ts b/src/components/Draft/useDraftFirstResponse.test.ts new file mode 100644 index 0000000..d106baa --- /dev/null +++ b/src/components/Draft/useDraftFirstResponse.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useDraftFirstResponse } from "./useDraftFirstResponse"; + +function makeOptions( + overrides: Partial[0]> = {}, +) { + return { + input: "user cannot log in", + ocrText: null, + currentTicket: null, + modelLoaded: true, + generateFirstResponse: vi + .fn() + .mockResolvedValue({ text: "Sorry for the trouble..." }), + onShowSuccess: vi.fn(), + onShowError: vi.fn(), + ...overrides, + }; +} + +describe("useDraftFirstResponse", () => { + it("blocks generation and errors when no model is loaded", async () => { + const onShowError = vi.fn(); + const options = makeOptions({ modelLoaded: false, onShowError }); + const { result } = renderHook(() => useDraftFirstResponse(options)); + + await act(async () => { + await result.current.handleGenerateFirstResponse(); + }); + + expect(onShowError).toHaveBeenCalledWith( + expect.stringContaining("No model loaded"), + ); + expect(options.generateFirstResponse).not.toHaveBeenCalled(); + expect(result.current.firstResponse).toBe(""); + }); + + it("stores generated text on successful generation", async () => { + const generateFirstResponse = vi + .fn() + .mockResolvedValue({ text: "Hello — I can help with the login issue." }); + const options = makeOptions({ generateFirstResponse }); + const { result } = renderHook(() => useDraftFirstResponse(options)); + + await act(async () => { + await result.current.handleGenerateFirstResponse(); + }); + + expect(generateFirstResponse).toHaveBeenCalledWith( + expect.objectContaining({ + user_input: "user cannot log in", + tone: "slack", + }), + ); + expect(result.current.firstResponse).toMatch(/login issue/i); + }); + + it("clears first response text on demand", () => { + const { result } = renderHook(() => useDraftFirstResponse(makeOptions())); + + act(() => { + result.current.setFirstResponse("a draft"); + }); + expect(result.current.firstResponse).toBe("a draft"); + + act(() => { + result.current.handleClearFirstResponse(); + }); + expect(result.current.firstResponse).toBe(""); + }); +}); diff --git a/src/components/Draft/useDraftFirstResponse.ts b/src/components/Draft/useDraftFirstResponse.ts new file mode 100644 index 0000000..9e87473 --- /dev/null +++ b/src/components/Draft/useDraftFirstResponse.ts @@ -0,0 +1,111 @@ +import { useCallback, useState } from "react"; +import type { JiraTicket } from "../../hooks/useJira"; +import type { FirstResponseTone } from "../../types/llm"; + +interface UseDraftFirstResponseOptions { + input: string; + ocrText: string | null; + currentTicket: JiraTicket | null; + modelLoaded: boolean; + generateFirstResponse: (params: { + user_input: string; + tone: FirstResponseTone; + ocr_text?: string; + jira_ticket?: JiraTicket; + }) => Promise<{ text: string }>; + onShowSuccess: (message: string) => void; + onShowError: (message: string) => void; +} + +export function useDraftFirstResponse({ + input, + ocrText, + currentTicket, + modelLoaded, + generateFirstResponse, + onShowSuccess, + onShowError, +}: UseDraftFirstResponseOptions) { + const [firstResponse, setFirstResponse] = useState(""); + const [firstResponseTone, setFirstResponseTone] = + useState("slack"); + const [firstResponseGenerating, setFirstResponseGenerating] = useState(false); + + const handleGenerateFirstResponse = useCallback(async () => { + if (firstResponseGenerating) return; + + if (!modelLoaded) { + onShowError("No model loaded. Go to Settings to load a model."); + return; + } + + const ticketFallback = currentTicket + ? `${currentTicket.summary}${currentTicket.description ? `\n\n${currentTicket.description}` : ""}` + : ""; + const promptInput = + input.trim() || ticketFallback.trim() || ocrText?.trim() || ""; + if (!promptInput) { + onShowError( + "Add ticket details or notes before generating a first response.", + ); + return; + } + + setFirstResponseGenerating(true); + try { + const result = await generateFirstResponse({ + user_input: promptInput, + tone: firstResponseTone, + ocr_text: ocrText ?? undefined, + jira_ticket: currentTicket ?? undefined, + }); + setFirstResponse(result.text); + } catch (e) { + console.error("First response generation failed:", e); + onShowError(`First response failed: ${e}`); + } finally { + setFirstResponseGenerating(false); + } + }, [ + input, + firstResponseGenerating, + modelLoaded, + generateFirstResponse, + firstResponseTone, + ocrText, + currentTicket, + onShowError, + ]); + + const handleCopyFirstResponse = useCallback(async () => { + if (!firstResponse.trim()) return; + try { + await navigator.clipboard.writeText(firstResponse); + onShowSuccess("First response copied to clipboard"); + } catch { + onShowError("Failed to copy first response"); + } + }, [firstResponse, onShowSuccess, onShowError]); + + const handleClearFirstResponse = useCallback(() => { + setFirstResponse(""); + }, []); + + const resetFirstResponse = useCallback(() => { + setFirstResponse(""); + setFirstResponseTone("slack"); + setFirstResponseGenerating(false); + }, []); + + return { + firstResponse, + setFirstResponse, + firstResponseTone, + setFirstResponseTone, + firstResponseGenerating, + handleGenerateFirstResponse, + handleCopyFirstResponse, + handleClearFirstResponse, + resetFirstResponse, + }; +} diff --git a/src/components/Draft/useDraftIntake.test.ts b/src/components/Draft/useDraftIntake.test.ts new file mode 100644 index 0000000..014c28b --- /dev/null +++ b/src/components/Draft/useDraftIntake.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { INTAKE_PRESETS, useDraftIntake } from "./useDraftIntake"; + +function makeOptions( + overrides: Partial[0]> = {}, +) { + return { + initialNoteAudience: "internal-note" as const, + input: "", + currentTicket: null, + currentTicketId: null, + response: "", + logEvent: vi.fn().mockResolvedValue(undefined), + setWorkspacePersonalization: vi.fn(), + ...overrides, + }; +} + +describe("useDraftIntake", () => { + it("updates individual intake fields", () => { + const { result } = renderHook(() => useDraftIntake(makeOptions())); + + act(() => { + result.current.handleIntakeFieldChange("issue", "VPN outage"); + }); + expect(result.current.caseIntake.issue).toBe("VPN outage"); + + act(() => { + result.current.handleIntakeFieldChange("impact", "west region"); + }); + expect(result.current.caseIntake.impact).toBe("west region"); + expect(result.current.caseIntake.issue).toBe("VPN outage"); + }); + + it("applies preset values and logs the event", () => { + const logEvent = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => + useDraftIntake(makeOptions({ logEvent })), + ); + + act(() => { + result.current.handleApplyIntakePreset("incident"); + }); + + expect(result.current.caseIntake.likely_category).toBe( + INTAKE_PRESETS.incident.likely_category, + ); + expect(result.current.caseIntake.urgency).toBe( + INTAKE_PRESETS.incident.urgency, + ); + expect(logEvent).toHaveBeenCalledWith("workspace_intake_preset_applied", { + preset: "incident", + }); + }); + + it("updates audience and syncs workspace personalization", () => { + const setWorkspacePersonalization = vi.fn(); + const { result } = renderHook(() => + useDraftIntake(makeOptions({ setWorkspacePersonalization })), + ); + + act(() => { + result.current.handleNoteAudienceChange("customer-safe"); + }); + + expect(result.current.caseIntake.note_audience).toBe("customer-safe"); + expect(setWorkspacePersonalization).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Draft/useDraftIntake.ts b/src/components/Draft/useDraftIntake.ts new file mode 100644 index 0000000..d1f0331 --- /dev/null +++ b/src/components/Draft/useDraftIntake.ts @@ -0,0 +1,119 @@ +import { useCallback, useState } from "react"; +import type { JiraTicket } from "../../hooks/useJira"; +import type { + CaseIntake, + NoteAudience, + WorkspacePersonalization, +} from "../../types/workspace"; +import { + analyzeCaseIntake, + parseCaseIntake, +} from "../../features/workspace/workspaceAssistant"; + +export type IntakePreset = "incident" | "access" | "rollout" | "device"; + +export const INTAKE_PRESETS: Record> = { + incident: { + likely_category: "incident", + urgency: "high", + note_audience: "internal-note", + }, + access: { + likely_category: "access", + urgency: "normal", + note_audience: "internal-note", + }, + rollout: { + likely_category: "change-rollout", + urgency: "normal", + note_audience: "internal-note", + }, + device: { + likely_category: "device-environment", + urgency: "normal", + note_audience: "internal-note", + }, +}; + +interface UseDraftIntakeOptions { + initialNoteAudience: NoteAudience; + input: string; + currentTicket: JiraTicket | null; + currentTicketId: string | null; + response: string; + logEvent: (event: string, payload?: Record) => Promise; + setWorkspacePersonalization: ( + updater: (prev: WorkspacePersonalization) => WorkspacePersonalization, + ) => void; +} + +export function useDraftIntake({ + initialNoteAudience, + input, + currentTicket, + currentTicketId, + response, + logEvent, + setWorkspacePersonalization, +}: UseDraftIntakeOptions) { + const [caseIntake, setCaseIntake] = useState(() => ({ + ...parseCaseIntake(null), + note_audience: initialNoteAudience, + })); + + const handleIntakeFieldChange = useCallback( + (field: keyof CaseIntake, value: string) => { + setCaseIntake((prev) => ({ + ...prev, + [field]: value, + })); + }, + [], + ); + + const handleAnalyzeIntake = useCallback(() => { + setCaseIntake((prev) => + analyzeCaseIntake(input, currentTicket ?? undefined, prev), + ); + void logEvent("workspace_intake_analyzed", { + ticket_id: currentTicketId, + has_ticket: Boolean(currentTicketId), + has_response: Boolean(response.trim()), + }); + }, [input, currentTicket, logEvent, currentTicketId, response]); + + const handleApplyIntakePreset = useCallback( + (preset: IntakePreset) => { + setCaseIntake((prev) => ({ + ...prev, + ...INTAKE_PRESETS[preset], + })); + void logEvent("workspace_intake_preset_applied", { preset }); + }, + [logEvent], + ); + + const handleNoteAudienceChange = useCallback( + (audience: NoteAudience) => { + setCaseIntake((prev) => ({ + ...prev, + note_audience: audience, + })); + setWorkspacePersonalization((prev) => ({ + ...prev, + preferred_note_audience: audience, + })); + void logEvent("workspace_note_audience_changed", { audience }); + }, + [logEvent, setWorkspacePersonalization], + ); + + return { + caseIntake, + setCaseIntake, + handleIntakeFieldChange, + handleAnalyzeIntake, + handleApplyIntakePreset, + handleNoteAudienceChange, + }; +}