diff --git a/src/components/Draft/DraftTab.tsx b/src/components/Draft/DraftTab.tsx index 9963d9b..3469d2c 100644 --- a/src/components/Draft/DraftTab.tsx +++ b/src/components/Draft/DraftTab.tsx @@ -6,7 +6,6 @@ import { useImperativeHandle, useMemo, } from "react"; -import { invoke } from "@tauri-apps/api/core"; import { DraftResponsePanel } from "./DraftResponsePanel"; import { InputPanel } from "./InputPanel"; import { DiagnosisPanel, TreeResult } from "./DiagnosisPanel"; @@ -18,6 +17,7 @@ import { useDraftGeneration } from "./useDraftGeneration"; import { useDraftIntake } from "./useDraftIntake"; import { useDraftPersistence } from "./useDraftPersistence"; import { useGuidedRunbook } from "./useGuidedRunbook"; +import { useResponseActions } from "./useResponseActions"; import { useWorkspaceArtifacts } from "./useWorkspaceArtifacts"; import { useWorkspaceClipboardPacks } from "./useWorkspaceClipboardPacks"; import { ConversationInput } from "./ConversationInput"; @@ -46,10 +46,7 @@ import { compactLines, parseCaseIntake, } from "../../features/workspace/workspaceAssistant"; -import { - calculateEditRatio, - countWords, -} from "../../features/analytics/qualityMetrics"; +import { countWords } from "../../features/analytics/qualityMetrics"; import type { JiraTicket } from "../../hooks/useJira"; import type { ConfidenceAssessment, @@ -339,10 +336,6 @@ export const DraftTab = forwardRef( } = useAlternatives(); const { suggestions, findSimilar, saveAsTemplate, incrementUsage } = useSavedResponses(); - const [showTemplateModal, setShowTemplateModal] = useState(false); - const [templateModalRating, setTemplateModalRating] = useState< - number | undefined - >(undefined); const [suggestionsDismissed, setSuggestionsDismissed] = useState(false); const { @@ -531,43 +524,37 @@ export const DraftTab = forwardRef( [savedDraftId], ); - const handleApplyTemplate = useCallback((content: string) => { - setResponse(content); - }, []); - - const handleSaveAsTemplate = useCallback((rating: number) => { - setTemplateModalRating(rating); - setShowTemplateModal(true); - }, []); - - const handleTemplateModalSave = useCallback( - async ( - name: string, - category: string | null, - content: string, - variablesJson: string | null, - ): Promise => { - const id = await saveAsTemplate(name, content, { - sourceDraftId: savedDraftId ?? undefined, - sourceRating: templateModalRating, - category: category ?? undefined, - variablesJson: variablesJson ?? undefined, - }); - if (id) { - showSuccess("Response saved as template"); - return true; - } - showError("Failed to save template"); - return false; - }, - [ - saveAsTemplate, - savedDraftId, - templateModalRating, - showSuccess, - showError, - ], - ); + const { + showTemplateModal, + setShowTemplateModal, + templateModalRating, + handleApplyTemplate, + handleSaveAsTemplate, + handleTemplateModalSave, + handleResponseChange, + handleCancel, + handleCopyResponse, + handleExportResponse, + resetResponseActions, + } = useResponseActions({ + response, + originalResponse, + isResponseEdited, + confidence, + sources, + savedDraftId, + streamingText, + cancelGeneration, + saveAsTemplate, + logEvent, + setResponse, + setOriginalResponse, + setIsResponseEdited, + setGenerating, + setHandoffTouched, + onShowSuccess: showSuccess, + onShowError: showError, + }); const handleSuggestionApply = useCallback( (content: string, templateId: string) => { @@ -620,8 +607,7 @@ export const DraftTab = forwardRef( setSavedDraftCreatedAt(null); setConversationEntries([]); setHandoffTouched(false); - setShowTemplateModal(false); - setTemplateModalRating(undefined); + resetResponseActions(); setSuggestionsDismissed(false); setCaseIntake({ ...parseCaseIntake(null), @@ -638,14 +624,6 @@ export const DraftTab = forwardRef( resetGeneration(); }, [workspacePersonalization.preferred_note_audience, resetGeneration]); - const handleResponseChange = useCallback( - (text: string) => { - setResponse(text); - setIsResponseEdited(text !== originalResponse); - }, - [originalResponse], - ); - const handleTreeComplete = useCallback((result: TreeResult) => { setTreeResult(result); }, []); @@ -724,17 +702,6 @@ export const DraftTab = forwardRef( [modelLoaded, responseLength, generateStreaming, clearStreamingText], ); - const handleCancel = useCallback(async () => { - await cancelGeneration(); - setGenerating(false); - // Keep the streaming text that was generated so far - if (streamingText) { - setResponse(streamingText); - setOriginalResponse(streamingText); - setIsResponseEdited(false); - } - }, [cancelGeneration, streamingText]); - useEffect(() => { if (viewMode !== "panels") { return; @@ -1191,73 +1158,6 @@ export const DraftTab = forwardRef( loadTemplates(); }, [loadTemplates]); - const handleCopyResponse = useCallback(async () => { - if (!response) return; - try { - const mode = confidence?.mode ?? "answer"; - const hasCitations = sources.length > 0; - const copyAllowed = mode === "answer" && hasCitations; - - if (!copyAllowed) { - const reason = window.prompt( - "Copy override required. This response is missing citations or is not in answer mode.\n\nEnter a reason to proceed (will be logged locally):", - ); - if (!reason || !reason.trim()) { - showError("Copy cancelled (reason required)."); - return; - } - await invoke("audit_response_copy_override", { - reason: reason.trim(), - confidenceMode: confidence?.mode ?? null, - sourcesCount: sources.length, - }); - } - await navigator.clipboard.writeText(response); - setHandoffTouched(true); - logEvent("response_copied", { - draft_id: savedDraftId, - word_count: countWords(response), - is_edited: isResponseEdited, - edit_ratio: Number( - calculateEditRatio(originalResponse, response).toFixed(3), - ), - }); - showSuccess("Response copied to clipboard"); - } catch { - showError("Failed to copy response"); - } - }, [ - response, - confidence?.mode, - sources.length, - showSuccess, - showError, - logEvent, - savedDraftId, - isResponseEdited, - originalResponse, - setHandoffTouched, - ]); - - const handleExportResponse = useCallback(async () => { - if (!response) { - showError("No response to export"); - return; - } - try { - const saved = await invoke("export_draft", { - responseText: response, - format: "Markdown", - }); - if (saved) { - setHandoffTouched(true); - showSuccess("Response exported successfully"); - } - } catch (e) { - showError(`Export failed: ${e}`); - } - }, [response, showSuccess, showError, setHandoffTouched]); - // Expose functions to parent via ref useImperativeHandle( ref, diff --git a/src/components/Draft/useResponseActions.test.ts b/src/components/Draft/useResponseActions.test.ts new file mode 100644 index 0000000..00ce133 --- /dev/null +++ b/src/components/Draft/useResponseActions.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const invokeMock = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (command: string, payload?: Record) => + invokeMock(command, payload), +})); + +import { useResponseActions } from "./useResponseActions"; + +type HookOptions = Parameters[0]; + +const writeText = vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + writeText.mockClear(); + invokeMock.mockReset(); + invokeMock.mockResolvedValue(true); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); +}); + +function makeOptions(overrides: Partial = {}): HookOptions { + return { + response: "generated text", + originalResponse: "generated text", + isResponseEdited: false, + confidence: { mode: "answer" } as HookOptions["confidence"], + sources: [ + { chunk_id: "s1", title: "Source 1", snippet: "x", score: 1 }, + ] as unknown as HookOptions["sources"], + savedDraftId: null, + streamingText: "", + + cancelGeneration: vi.fn(), + saveAsTemplate: vi.fn().mockResolvedValue("tpl-1"), + logEvent: vi.fn(), + + setResponse: vi.fn(), + setOriginalResponse: vi.fn(), + setIsResponseEdited: vi.fn(), + setGenerating: vi.fn(), + setHandoffTouched: vi.fn(), + + onShowSuccess: vi.fn(), + onShowError: vi.fn(), + ...overrides, + }; +} + +describe("useResponseActions", () => { + it("copies the response directly when mode is answer and citations exist", async () => { + const options = makeOptions(); + const { result } = renderHook(() => useResponseActions(options)); + + await act(async () => { + await result.current.handleCopyResponse(); + }); + + expect(writeText).toHaveBeenCalledWith("generated text"); + expect(invokeMock).not.toHaveBeenCalledWith( + "audit_response_copy_override", + expect.anything(), + ); + expect(options.setHandoffTouched).toHaveBeenCalledWith(true); + expect(options.onShowSuccess).toHaveBeenCalledWith( + "Response copied to clipboard", + ); + }); + + it("requires a reason when copy guard would otherwise block", async () => { + const promptSpy = vi + .spyOn(window, "prompt") + .mockReturnValue("ops needs this now"); + const options = makeOptions({ sources: [] }); + const { result } = renderHook(() => useResponseActions(options)); + + await act(async () => { + await result.current.handleCopyResponse(); + }); + + expect(promptSpy).toHaveBeenCalled(); + expect(invokeMock).toHaveBeenCalledWith("audit_response_copy_override", { + reason: "ops needs this now", + confidenceMode: "answer", + sourcesCount: 0, + }); + expect(writeText).toHaveBeenCalled(); + }); + + it("cancels and returns early when the user declines the override prompt", async () => { + vi.spyOn(window, "prompt").mockReturnValue(""); + const options = makeOptions({ sources: [] }); + const { result } = renderHook(() => useResponseActions(options)); + + await act(async () => { + await result.current.handleCopyResponse(); + }); + + expect(writeText).not.toHaveBeenCalled(); + expect(options.onShowError).toHaveBeenCalledWith( + "Copy cancelled (reason required).", + ); + }); + + it("exports the response and marks handoff touched", async () => { + const options = makeOptions(); + const { result } = renderHook(() => useResponseActions(options)); + + await act(async () => { + await result.current.handleExportResponse(); + }); + + expect(invokeMock).toHaveBeenCalledWith("export_draft", { + responseText: "generated text", + format: "Markdown", + }); + expect(options.setHandoffTouched).toHaveBeenCalledWith(true); + expect(options.onShowSuccess).toHaveBeenCalledWith( + "Response exported successfully", + ); + }); + + it("keeps partial streaming text on cancel", async () => { + const options = makeOptions({ streamingText: "partial..." }); + const { result } = renderHook(() => useResponseActions(options)); + + await act(async () => { + await result.current.handleCancel(); + }); + + expect(options.cancelGeneration).toHaveBeenCalled(); + expect(options.setGenerating).toHaveBeenCalledWith(false); + expect(options.setResponse).toHaveBeenCalledWith("partial..."); + expect(options.setOriginalResponse).toHaveBeenCalledWith("partial..."); + expect(options.setIsResponseEdited).toHaveBeenCalledWith(false); + }); + + it("flags edited when response changes from the original", () => { + const options = makeOptions({ originalResponse: "orig" }); + const { result } = renderHook(() => useResponseActions(options)); + + act(() => { + result.current.handleResponseChange("new text"); + }); + + expect(options.setResponse).toHaveBeenCalledWith("new text"); + expect(options.setIsResponseEdited).toHaveBeenCalledWith(true); + }); + + it("opens the template modal and stores the rating when saveAsTemplate is called", () => { + const options = makeOptions(); + const { result } = renderHook(() => useResponseActions(options)); + + act(() => { + result.current.handleSaveAsTemplate(4); + }); + + expect(result.current.showTemplateModal).toBe(true); + expect(result.current.templateModalRating).toBe(4); + }); +}); diff --git a/src/components/Draft/useResponseActions.ts b/src/components/Draft/useResponseActions.ts new file mode 100644 index 0000000..c22f73a --- /dev/null +++ b/src/components/Draft/useResponseActions.ts @@ -0,0 +1,219 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useState } from "react"; +import { + calculateEditRatio, + countWords, +} from "../../features/analytics/qualityMetrics"; +import type { ConfidenceAssessment } from "../../types/llm"; +import type { ContextSource } from "../../types/knowledge"; + +interface TemplateSaveOptions { + sourceDraftId?: string; + sourceRating?: number; + category?: string; + variablesJson?: string; +} + +interface UseResponseActionsOptions { + response: string; + originalResponse: string; + isResponseEdited: boolean; + confidence: ConfidenceAssessment | null; + sources: ContextSource[]; + savedDraftId: string | null; + streamingText: string; + + cancelGeneration: () => void; + saveAsTemplate: ( + name: string, + content: string, + options: TemplateSaveOptions, + ) => Promise; + logEvent: (event: string, payload?: Record) => unknown; + + setResponse: (value: string) => void; + setOriginalResponse: (value: string) => void; + setIsResponseEdited: (value: boolean) => void; + setGenerating: (value: boolean) => void; + setHandoffTouched: (value: boolean) => void; + + onShowSuccess: (message: string) => void; + onShowError: (message: string) => void; +} + +export function useResponseActions({ + response, + originalResponse, + isResponseEdited, + confidence, + sources, + savedDraftId, + streamingText, + cancelGeneration, + saveAsTemplate, + logEvent, + setResponse, + setOriginalResponse, + setIsResponseEdited, + setGenerating, + setHandoffTouched, + onShowSuccess, + onShowError, +}: UseResponseActionsOptions) { + const [showTemplateModal, setShowTemplateModal] = useState(false); + const [templateModalRating, setTemplateModalRating] = useState< + number | undefined + >(undefined); + + const handleApplyTemplate = useCallback( + (content: string) => { + setResponse(content); + }, + [setResponse], + ); + + const handleSaveAsTemplate = useCallback((rating: number) => { + setTemplateModalRating(rating); + setShowTemplateModal(true); + }, []); + + const handleTemplateModalSave = useCallback( + async ( + name: string, + category: string | null, + content: string, + variablesJson: string | null, + ): Promise => { + const id = await saveAsTemplate(name, content, { + sourceDraftId: savedDraftId ?? undefined, + sourceRating: templateModalRating, + category: category ?? undefined, + variablesJson: variablesJson ?? undefined, + }); + if (id) { + onShowSuccess("Response saved as template"); + return true; + } + onShowError("Failed to save template"); + return false; + }, + [ + saveAsTemplate, + savedDraftId, + templateModalRating, + onShowSuccess, + onShowError, + ], + ); + + const handleResponseChange = useCallback( + (text: string) => { + setResponse(text); + setIsResponseEdited(text !== originalResponse); + }, + [originalResponse, setResponse, setIsResponseEdited], + ); + + const handleCancel = useCallback(async () => { + await cancelGeneration(); + setGenerating(false); + if (streamingText) { + setResponse(streamingText); + setOriginalResponse(streamingText); + setIsResponseEdited(false); + } + }, [ + cancelGeneration, + streamingText, + setGenerating, + setResponse, + setOriginalResponse, + setIsResponseEdited, + ]); + + const handleCopyResponse = useCallback(async () => { + if (!response) return; + try { + const mode = confidence?.mode ?? "answer"; + const hasCitations = sources.length > 0; + const copyAllowed = mode === "answer" && hasCitations; + + if (!copyAllowed) { + const reason = window.prompt( + "Copy override required. This response is missing citations or is not in answer mode.\n\nEnter a reason to proceed (will be logged locally):", + ); + if (!reason || !reason.trim()) { + onShowError("Copy cancelled (reason required)."); + return; + } + await invoke("audit_response_copy_override", { + reason: reason.trim(), + confidenceMode: confidence?.mode ?? null, + sourcesCount: sources.length, + }); + } + await navigator.clipboard.writeText(response); + setHandoffTouched(true); + logEvent("response_copied", { + draft_id: savedDraftId, + word_count: countWords(response), + is_edited: isResponseEdited, + edit_ratio: Number( + calculateEditRatio(originalResponse, response).toFixed(3), + ), + }); + onShowSuccess("Response copied to clipboard"); + } catch { + onShowError("Failed to copy response"); + } + }, [ + response, + confidence?.mode, + sources.length, + logEvent, + savedDraftId, + isResponseEdited, + originalResponse, + setHandoffTouched, + onShowSuccess, + onShowError, + ]); + + const handleExportResponse = useCallback(async () => { + if (!response) { + onShowError("No response to export"); + return; + } + try { + const saved = await invoke("export_draft", { + responseText: response, + format: "Markdown", + }); + if (saved) { + setHandoffTouched(true); + onShowSuccess("Response exported successfully"); + } + } catch (e) { + onShowError(`Export failed: ${e}`); + } + }, [response, setHandoffTouched, onShowSuccess, onShowError]); + + const resetResponseActions = useCallback(() => { + setShowTemplateModal(false); + setTemplateModalRating(undefined); + }, []); + + return { + showTemplateModal, + setShowTemplateModal, + templateModalRating, + handleApplyTemplate, + handleSaveAsTemplate, + handleTemplateModalSave, + handleResponseChange, + handleCancel, + handleCopyResponse, + handleExportResponse, + resetResponseActions, + }; +}