diff --git a/src/components/Draft/DraftTab.tsx b/src/components/Draft/DraftTab.tsx index 5f447cd..83dd28e 100644 --- a/src/components/Draft/DraftTab.tsx +++ b/src/components/Draft/DraftTab.tsx @@ -16,6 +16,8 @@ import { useDraftChecklist } from "./useDraftChecklist"; import { useDraftFirstResponse } from "./useDraftFirstResponse"; import { useDraftGeneration } from "./useDraftGeneration"; import { useDraftIntake } from "./useDraftIntake"; +import { useGuidedRunbook } from "./useGuidedRunbook"; +import { useWorkspaceClipboardPacks } from "./useWorkspaceClipboardPacks"; import { ConversationInput } from "./ConversationInput"; import { WorkspaceDialogs } from "./WorkspaceDialogs"; import { WorkspaceModeShell } from "./WorkspaceModeShell"; @@ -43,9 +45,6 @@ import { buildResolutionKitFromWorkspace, buildSimilarCases, compactLines, - formatEvidencePackForClipboard, - formatHandoffPackForClipboard, - formatKbDraftForClipboard, parseCaseIntake, } from "../../features/workspace/workspaceAssistant"; import { @@ -337,7 +336,6 @@ export const DraftTab = forwardRef( const [similarCases, setSimilarCases] = useState([]); const [similarCasesLoading, setSimilarCasesLoading] = useState(false); const [compareCase, setCompareCase] = useState(null); - const [guidedRunbookNote, setGuidedRunbookNote] = useState(""); const [workspaceRunbookScopeKey, setWorkspaceRunbookScopeKey] = useState(createWorkspaceRunbookScopeKey); const [autosaveDraftId, setAutosaveDraftId] = useState(null); @@ -488,6 +486,31 @@ export const DraftTab = forwardRef( setWorkspacePersonalization, }); + const { + guidedRunbookNote, + setGuidedRunbookNote, + handleStartGuidedRunbook, + handleAdvanceGuidedRunbook, + handleCopyRunbookProgressToNotes, + handleGuidedRunbookNoteChange, + } = useGuidedRunbook({ + runbookTemplates, + guidedRunbookSession, + workspaceRunbookScopeKey, + currentTicketId, + startRunbookSession, + addRunbookStepEvidence, + advanceRunbookSession, + refreshWorkspaceCatalog, + logEvent, + setDiagnosticNotes, + setPanelDensityMode, + setRunbookSessionSourceScopeKey, + setRunbookSessionTouched, + onShowSuccess: showSuccess, + onShowError: showError, + }); + const handleResponseLengthChange = useCallback((length: ResponseLength) => { setResponseLength(length); setWorkspacePersonalization((prev) => ({ @@ -1026,116 +1049,20 @@ export const DraftTab = forwardRef( setOcrText, }); - const handleCopyHandoffPack = useCallback(async () => { - try { - await navigator.clipboard.writeText( - formatHandoffPackForClipboard(handoffPack), - ); - setHandoffTouched(true); - if (savedDraftId) { - await saveCaseOutcome({ - draft_id: savedDraftId, - status: "handoff-ready", - outcome_summary: handoffPack.summary, - handoff_pack_json: JSON.stringify(handoffPack), - kb_draft_json: JSON.stringify(kbDraft), - evidence_pack_json: JSON.stringify(evidencePack), - tags_json: JSON.stringify( - [caseIntake.likely_category].filter(Boolean), - ), - }); - } - void logEvent("workspace_handoff_pack_copied", { - ticket_id: currentTicketId, - note_audience: caseIntake.note_audience, - }); - showSuccess("Handoff pack copied"); - } catch { - showError("Failed to copy handoff pack"); - } - }, [ - handoffPack, - savedDraftId, - saveCaseOutcome, - kbDraft, - evidencePack, - caseIntake.likely_category, - logEvent, - currentTicketId, - caseIntake.note_audience, - showSuccess, - showError, - ]); - - const handleCopyEvidencePack = useCallback(async () => { - try { - await navigator.clipboard.writeText( - formatEvidencePackForClipboard(evidencePack), - ); - if (savedDraftId) { - await saveCaseOutcome({ - draft_id: savedDraftId, - status: "evidence-ready", - outcome_summary: evidencePack.summary, - handoff_pack_json: JSON.stringify(handoffPack), - kb_draft_json: JSON.stringify(kbDraft), - evidence_pack_json: JSON.stringify(evidencePack), - tags_json: JSON.stringify(kbDraft.tags), - }); - } - void logEvent("workspace_evidence_pack_copied", { - ticket_id: currentTicketId, - }); - showSuccess("Evidence pack copied"); - } catch { - showError("Failed to copy evidence pack"); - } - }, [ - evidencePack, - savedDraftId, - saveCaseOutcome, - handoffPack, - kbDraft, - logEvent, - currentTicketId, - showSuccess, - showError, - ]); - - const handleCopyKbDraft = useCallback(async () => { - try { - await navigator.clipboard.writeText(formatKbDraftForClipboard(kbDraft)); - if (savedDraftId) { - await saveCaseOutcome({ - draft_id: savedDraftId, - status: "kb-promoted", - outcome_summary: kbDraft.summary, - handoff_pack_json: JSON.stringify(handoffPack), - kb_draft_json: JSON.stringify(kbDraft), - evidence_pack_json: JSON.stringify(evidencePack), - tags_json: JSON.stringify(kbDraft.tags), - }); - } - void logEvent("workspace_kb_draft_copied", { - ticket_id: currentTicketId, - category: caseIntake.likely_category, - }); - showSuccess("KB draft copied"); - } catch { - showError("Failed to copy KB draft"); - } - }, [ - kbDraft, - saveCaseOutcome, - savedDraftId, - handoffPack, - evidencePack, - logEvent, - currentTicketId, - caseIntake.likely_category, - showSuccess, - showError, - ]); + const { handleCopyHandoffPack, handleCopyEvidencePack, handleCopyKbDraft } = + useWorkspaceClipboardPacks({ + handoffPack, + evidencePack, + kbDraft, + caseIntake, + savedDraftId, + currentTicketId, + saveCaseOutcome, + logEvent, + onHandoffCopied: () => setHandoffTouched(true), + onShowSuccess: showSuccess, + onShowError: showError, + }); const handleSaveCurrentResolutionKit = useCallback(async () => { try { @@ -1247,167 +1174,6 @@ export const DraftTab = forwardRef( ], ); - const handleStartGuidedRunbook = useCallback( - async (templateId: string) => { - const template = runbookTemplates.find( - (item) => item.id === templateId, - ); - if (!template) { - showError("Choose a guided runbook template first"); - return; - } - if ( - guidedRunbookSession && - guidedRunbookSession.status !== "completed" - ) { - showError( - "Finish the current guided runbook before starting another one", - ); - return; - } - - try { - await startRunbookSession( - template.scenario, - template.steps, - workspaceRunbookScopeKey, - ); - setGuidedRunbookNote(""); - setRunbookSessionSourceScopeKey(workspaceRunbookScopeKey); - setRunbookSessionTouched(true); - await refreshWorkspaceCatalog(); - setPanelDensityMode("focus-intake"); - void logEvent("workspace_guided_runbook_started", { - ticket_id: currentTicketId, - template_id: template.id, - scenario: template.scenario, - }); - showSuccess(`Started ${template.name}`); - } catch { - showError("Failed to start guided runbook"); - } - }, - [ - runbookTemplates, - startRunbookSession, - refreshWorkspaceCatalog, - workspaceRunbookScopeKey, - guidedRunbookSession, - logEvent, - currentTicketId, - showSuccess, - showError, - ], - ); - - const handleAdvanceGuidedRunbook = useCallback( - async (status: "completed" | "skipped" | "failed") => { - if (!guidedRunbookSession) { - showError("Start a guided runbook before updating a step"); - return; - } - - const currentStep = guidedRunbookSession.current_step; - const stepLabel = - guidedRunbookSession.steps[currentStep] ?? `Step ${currentStep + 1}`; - const noteText = guidedRunbookNote.trim(); - const evidenceText = noteText || `${status} · ${stepLabel}`; - const skipReason = - status === "skipped" - ? noteText || "Skipped from workspace" - : undefined; - const nextStep = - status === "failed" - ? currentStep - : Math.min( - currentStep + 1, - Math.max(guidedRunbookSession.steps.length - 1, 0), - ); - const nextStatus = - status === "failed" - ? "paused" - : currentStep >= guidedRunbookSession.steps.length - 1 - ? "completed" - : "active"; - - try { - await addRunbookStepEvidence( - guidedRunbookSession.id, - currentStep, - status, - evidenceText, - skipReason, - ); - await advanceRunbookSession( - guidedRunbookSession.id, - nextStep, - nextStatus, - ); - setRunbookSessionTouched(true); - if (noteText) { - setDiagnosticNotes((prev) => - compactLines([prev, `Runbook ${stepLabel}: ${noteText}`]), - ); - } - setGuidedRunbookNote(""); - await refreshWorkspaceCatalog(); - void logEvent("workspace_guided_runbook_step_recorded", { - ticket_id: currentTicketId, - session_id: guidedRunbookSession.id, - step_index: currentStep, - status, - }); - showSuccess( - status === "failed" - ? `Paused the runbook at ${stepLabel}` - : nextStatus === "completed" - ? "Guided runbook completed" - : `Recorded ${stepLabel}`, - ); - } catch { - showError("Failed to update guided runbook progress"); - } - }, - [ - guidedRunbookSession, - guidedRunbookNote, - addRunbookStepEvidence, - advanceRunbookSession, - refreshWorkspaceCatalog, - currentTicketId, - logEvent, - showSuccess, - showError, - ], - ); - - const handleCopyRunbookProgressToNotes = useCallback(() => { - if (!guidedRunbookSession || guidedRunbookSession.evidence.length === 0) { - showError("No guided runbook progress to copy yet"); - return; - } - - const progressText = compactLines([ - `Guided runbook: ${guidedRunbookSession.scenario}`, - ...guidedRunbookSession.evidence.map((item) => { - const stepLabel = - guidedRunbookSession.steps[item.step_index] ?? - `Step ${item.step_index + 1}`; - return `- ${stepLabel}: ${item.status}${item.evidence_text ? ` · ${item.evidence_text}` : ""}`; - }), - ]); - - setDiagnosticNotes((prev) => compactLines([prev, progressText])); - showSuccess("Copied guided runbook progress into the notes"); - }, [guidedRunbookSession, showError, showSuccess]); - - const handleGuidedRunbookNoteChange = useCallback((value: string) => { - setGuidedRunbookNote(value); - if (value.trim()) { - setRunbookSessionTouched(true); - } - }, []); - const loadSimilarCaseIntoWorkspace = useCallback( async (similarCase: SimilarCase) => { const fullDraft = await getDraft(similarCase.draft_id); diff --git a/src/components/Draft/useGuidedRunbook.test.ts b/src/components/Draft/useGuidedRunbook.test.ts new file mode 100644 index 0000000..5025e46 --- /dev/null +++ b/src/components/Draft/useGuidedRunbook.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useGuidedRunbook } from "./useGuidedRunbook"; + +function makeSession( + overrides: Partial< + NonNullable[0]["guidedRunbookSession"]> + > = {}, +) { + return { + id: "session-1", + scenario: "security-incident", + steps: ["Ack", "Contain", "Notify"], + current_step: 0, + status: "active" as const, + scope_key: "workspace:test", + started_at: "", + updated_at: "", + evidence: [], + ...overrides, + } as unknown as NonNullable< + Parameters[0]["guidedRunbookSession"] + >; +} + +function makeOptions( + overrides: Partial[0]> = {}, +) { + return { + runbookTemplates: [ + { + id: "tpl-1", + name: "Security", + scenario: "security-incident", + steps: ["Ack", "Contain", "Notify"], + }, + ], + guidedRunbookSession: null, + workspaceRunbookScopeKey: "workspace:test", + currentTicketId: null, + startRunbookSession: vi.fn().mockResolvedValue(undefined), + addRunbookStepEvidence: vi.fn().mockResolvedValue(undefined), + advanceRunbookSession: vi.fn().mockResolvedValue(undefined), + refreshWorkspaceCatalog: vi.fn().mockResolvedValue(undefined), + logEvent: vi.fn(), + setDiagnosticNotes: vi.fn(), + setPanelDensityMode: vi.fn(), + setRunbookSessionSourceScopeKey: vi.fn(), + setRunbookSessionTouched: vi.fn(), + onShowSuccess: vi.fn(), + onShowError: vi.fn(), + ...overrides, + }; +} + +describe("useGuidedRunbook", () => { + it("refuses to start an unknown template", async () => { + const options = makeOptions(); + const { result } = renderHook(() => useGuidedRunbook(options)); + + await act(async () => { + await result.current.handleStartGuidedRunbook("missing-id"); + }); + + expect(options.onShowError).toHaveBeenCalledWith( + "Choose a guided runbook template first", + ); + expect(options.startRunbookSession).not.toHaveBeenCalled(); + }); + + it("refuses to start a new runbook while an active one is in progress", async () => { + const options = makeOptions({ + guidedRunbookSession: makeSession({ status: "active" }), + }); + const { result } = renderHook(() => useGuidedRunbook(options)); + + await act(async () => { + await result.current.handleStartGuidedRunbook("tpl-1"); + }); + + expect(options.onShowError).toHaveBeenCalledWith( + expect.stringContaining("Finish the current guided runbook"), + ); + expect(options.startRunbookSession).not.toHaveBeenCalled(); + }); + + it("starts a session, refreshes catalog, and focuses intake", async () => { + const options = makeOptions(); + const { result } = renderHook(() => useGuidedRunbook(options)); + + await act(async () => { + await result.current.handleStartGuidedRunbook("tpl-1"); + }); + + expect(options.startRunbookSession).toHaveBeenCalledWith( + "security-incident", + ["Ack", "Contain", "Notify"], + "workspace:test", + ); + expect(options.setPanelDensityMode).toHaveBeenCalledWith("focus-intake"); + expect(options.setRunbookSessionTouched).toHaveBeenCalledWith(true); + expect(options.onShowSuccess).toHaveBeenCalledWith("Started Security"); + }); + + it("handleCopyRunbookProgressToNotes errors when there is no evidence yet", () => { + const options = makeOptions({ + guidedRunbookSession: makeSession({ evidence: [] }), + }); + const { result } = renderHook(() => useGuidedRunbook(options)); + + act(() => { + result.current.handleCopyRunbookProgressToNotes(); + }); + + expect(options.onShowError).toHaveBeenCalledWith( + "No guided runbook progress to copy yet", + ); + expect(options.setDiagnosticNotes).not.toHaveBeenCalled(); + }); + + it("note change sets touched only when value has content", () => { + const options = makeOptions(); + const { result } = renderHook(() => useGuidedRunbook(options)); + + act(() => { + result.current.handleGuidedRunbookNoteChange(" "); + }); + expect(options.setRunbookSessionTouched).not.toHaveBeenCalled(); + + act(() => { + result.current.handleGuidedRunbookNoteChange("real note"); + }); + expect(options.setRunbookSessionTouched).toHaveBeenCalledWith(true); + expect(result.current.guidedRunbookNote).toBe("real note"); + }); +}); diff --git a/src/components/Draft/useGuidedRunbook.ts b/src/components/Draft/useGuidedRunbook.ts new file mode 100644 index 0000000..f0de430 --- /dev/null +++ b/src/components/Draft/useGuidedRunbook.ts @@ -0,0 +1,231 @@ +import { useCallback, useState } from "react"; +import { compactLines } from "../../features/workspace/workspaceAssistant"; +import type { + GuidedRunbookSession, + GuidedRunbookTemplate, +} from "../../types/workspace"; + +type PanelDensityMode = "balanced" | "focus-intake" | "focus-response"; + +interface UseGuidedRunbookOptions { + runbookTemplates: GuidedRunbookTemplate[]; + guidedRunbookSession: GuidedRunbookSession | null; + workspaceRunbookScopeKey: string; + currentTicketId: string | null; + startRunbookSession: ( + scenario: string, + steps: string[], + scopeKey: string, + ) => Promise; + addRunbookStepEvidence: ( + sessionId: string, + stepIndex: number, + status: "completed" | "skipped" | "failed", + evidenceText: string, + skipReason?: string, + ) => Promise; + advanceRunbookSession: ( + sessionId: string, + nextStep: number, + nextStatus: "active" | "paused" | "completed", + ) => Promise; + refreshWorkspaceCatalog: () => Promise; + logEvent: (event: string, payload?: Record) => unknown; + setDiagnosticNotes: (updater: (prev: string) => string) => void; + setPanelDensityMode: (mode: PanelDensityMode) => void; + setRunbookSessionSourceScopeKey: (key: string | null) => void; + setRunbookSessionTouched: (touched: boolean) => void; + onShowSuccess: (message: string) => void; + onShowError: (message: string) => void; +} + +export function useGuidedRunbook({ + runbookTemplates, + guidedRunbookSession, + workspaceRunbookScopeKey, + currentTicketId, + startRunbookSession, + addRunbookStepEvidence, + advanceRunbookSession, + refreshWorkspaceCatalog, + logEvent, + setDiagnosticNotes, + setPanelDensityMode, + setRunbookSessionSourceScopeKey, + setRunbookSessionTouched, + onShowSuccess, + onShowError, +}: UseGuidedRunbookOptions) { + const [guidedRunbookNote, setGuidedRunbookNote] = useState(""); + + const handleStartGuidedRunbook = useCallback( + async (templateId: string) => { + const template = runbookTemplates.find((item) => item.id === templateId); + if (!template) { + onShowError("Choose a guided runbook template first"); + return; + } + if (guidedRunbookSession && guidedRunbookSession.status !== "completed") { + onShowError( + "Finish the current guided runbook before starting another one", + ); + return; + } + + try { + await startRunbookSession( + template.scenario, + template.steps, + workspaceRunbookScopeKey, + ); + setGuidedRunbookNote(""); + setRunbookSessionSourceScopeKey(workspaceRunbookScopeKey); + setRunbookSessionTouched(true); + await refreshWorkspaceCatalog(); + setPanelDensityMode("focus-intake"); + void logEvent("workspace_guided_runbook_started", { + ticket_id: currentTicketId, + template_id: template.id, + scenario: template.scenario, + }); + onShowSuccess(`Started ${template.name}`); + } catch { + onShowError("Failed to start guided runbook"); + } + }, + [ + runbookTemplates, + startRunbookSession, + refreshWorkspaceCatalog, + workspaceRunbookScopeKey, + guidedRunbookSession, + logEvent, + currentTicketId, + setPanelDensityMode, + setRunbookSessionSourceScopeKey, + setRunbookSessionTouched, + onShowSuccess, + onShowError, + ], + ); + + const handleAdvanceGuidedRunbook = useCallback( + async (status: "completed" | "skipped" | "failed") => { + if (!guidedRunbookSession) { + onShowError("Start a guided runbook before updating a step"); + return; + } + + const currentStep = guidedRunbookSession.current_step; + const stepLabel = + guidedRunbookSession.steps[currentStep] ?? `Step ${currentStep + 1}`; + const noteText = guidedRunbookNote.trim(); + const evidenceText = noteText || `${status} · ${stepLabel}`; + const skipReason = + status === "skipped" ? noteText || "Skipped from workspace" : undefined; + const nextStep = + status === "failed" + ? currentStep + : Math.min( + currentStep + 1, + Math.max(guidedRunbookSession.steps.length - 1, 0), + ); + const nextStatus = + status === "failed" + ? "paused" + : currentStep >= guidedRunbookSession.steps.length - 1 + ? "completed" + : "active"; + + try { + await addRunbookStepEvidence( + guidedRunbookSession.id, + currentStep, + status, + evidenceText, + skipReason, + ); + await advanceRunbookSession( + guidedRunbookSession.id, + nextStep, + nextStatus, + ); + setRunbookSessionTouched(true); + if (noteText) { + setDiagnosticNotes((prev) => + compactLines([prev, `Runbook ${stepLabel}: ${noteText}`]), + ); + } + setGuidedRunbookNote(""); + await refreshWorkspaceCatalog(); + void logEvent("workspace_guided_runbook_step_recorded", { + ticket_id: currentTicketId, + session_id: guidedRunbookSession.id, + step_index: currentStep, + status, + }); + onShowSuccess( + status === "failed" + ? `Paused the runbook at ${stepLabel}` + : nextStatus === "completed" + ? "Guided runbook completed" + : `Recorded ${stepLabel}`, + ); + } catch { + onShowError("Failed to update guided runbook progress"); + } + }, + [ + guidedRunbookSession, + guidedRunbookNote, + addRunbookStepEvidence, + advanceRunbookSession, + refreshWorkspaceCatalog, + currentTicketId, + logEvent, + setDiagnosticNotes, + setRunbookSessionTouched, + onShowSuccess, + onShowError, + ], + ); + + const handleCopyRunbookProgressToNotes = useCallback(() => { + if (!guidedRunbookSession || guidedRunbookSession.evidence.length === 0) { + onShowError("No guided runbook progress to copy yet"); + return; + } + + const progressText = compactLines([ + `Guided runbook: ${guidedRunbookSession.scenario}`, + ...guidedRunbookSession.evidence.map((item) => { + const stepLabel = + guidedRunbookSession.steps[item.step_index] ?? + `Step ${item.step_index + 1}`; + return `- ${stepLabel}: ${item.status}${item.evidence_text ? ` · ${item.evidence_text}` : ""}`; + }), + ]); + + setDiagnosticNotes((prev) => compactLines([prev, progressText])); + onShowSuccess("Copied guided runbook progress into the notes"); + }, [guidedRunbookSession, setDiagnosticNotes, onShowError, onShowSuccess]); + + const handleGuidedRunbookNoteChange = useCallback( + (value: string) => { + setGuidedRunbookNote(value); + if (value.trim()) { + setRunbookSessionTouched(true); + } + }, + [setRunbookSessionTouched], + ); + + return { + guidedRunbookNote, + setGuidedRunbookNote, + handleStartGuidedRunbook, + handleAdvanceGuidedRunbook, + handleCopyRunbookProgressToNotes, + handleGuidedRunbookNoteChange, + }; +} diff --git a/src/components/Draft/useWorkspaceClipboardPacks.test.ts b/src/components/Draft/useWorkspaceClipboardPacks.test.ts new file mode 100644 index 0000000..9cb9097 --- /dev/null +++ b/src/components/Draft/useWorkspaceClipboardPacks.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../features/workspace/workspaceAssistant", () => ({ + formatHandoffPackForClipboard: (pack: { summary: string }) => + `handoff:${pack.summary}`, + formatEvidencePackForClipboard: (pack: { summary: string }) => + `evidence:${pack.summary}`, + formatKbDraftForClipboard: (pack: { summary: string }) => + `kb:${pack.summary}`, +})); + +import { useWorkspaceClipboardPacks } from "./useWorkspaceClipboardPacks"; + +const writeText = vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + writeText.mockClear(); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); +}); + +function makeOptions( + overrides: Partial[0]> = {}, +) { + const handoffPack = { + summary: "handoff summary", + nextActions: [], + operatorNotes: "", + ticketId: null, + sources: [], + confidence: null, + grounding: [], + } as unknown as Parameters< + typeof useWorkspaceClipboardPacks + >[0]["handoffPack"]; + + const evidencePack = { + summary: "evidence summary", + sections: [], + } as unknown as Parameters< + typeof useWorkspaceClipboardPacks + >[0]["evidencePack"]; + + const kbDraft = { + summary: "kb summary", + body: "", + tags: ["access"], + } as unknown as Parameters[0]["kbDraft"]; + + const caseIntake = { + issue: "", + environment: "", + impact: "", + affected_user: "", + affected_system: "", + affected_site: "", + symptoms: "", + steps_tried: "", + blockers: "", + likely_category: "access", + urgency: "normal", + note_audience: "internal-note", + } as unknown as Parameters< + typeof useWorkspaceClipboardPacks + >[0]["caseIntake"]; + + return { + handoffPack, + evidencePack, + kbDraft, + caseIntake, + savedDraftId: null, + currentTicketId: null, + saveCaseOutcome: vi.fn().mockResolvedValue(undefined), + logEvent: vi.fn(), + onHandoffCopied: vi.fn(), + onShowSuccess: vi.fn(), + onShowError: vi.fn(), + ...overrides, + }; +} + +describe("useWorkspaceClipboardPacks", () => { + it("writes the handoff pack, triggers the copied callback, and shows success", async () => { + const options = makeOptions(); + const { result } = renderHook(() => useWorkspaceClipboardPacks(options)); + + await act(async () => { + await result.current.handleCopyHandoffPack(); + }); + + expect(writeText).toHaveBeenCalled(); + expect(options.onHandoffCopied).toHaveBeenCalledTimes(1); + expect(options.onShowSuccess).toHaveBeenCalledWith("Handoff pack copied"); + }); + + it("skips saveCaseOutcome when there is no savedDraftId", async () => { + const options = makeOptions(); + const { result } = renderHook(() => useWorkspaceClipboardPacks(options)); + + await act(async () => { + await result.current.handleCopyEvidencePack(); + }); + + expect(options.saveCaseOutcome).not.toHaveBeenCalled(); + expect(options.onShowSuccess).toHaveBeenCalledWith("Evidence pack copied"); + }); + + it("saves case outcome when a savedDraftId is present for the KB draft copy", async () => { + const options = makeOptions({ savedDraftId: "draft-9" }); + const { result } = renderHook(() => useWorkspaceClipboardPacks(options)); + + await act(async () => { + await result.current.handleCopyKbDraft(); + }); + + expect(options.saveCaseOutcome).toHaveBeenCalledWith( + expect.objectContaining({ + draft_id: "draft-9", + status: "kb-promoted", + }), + ); + expect(options.onShowSuccess).toHaveBeenCalledWith("KB draft copied"); + }); + + it("surfaces an error when the clipboard write rejects", async () => { + writeText.mockRejectedValueOnce(new Error("no clipboard")); + const options = makeOptions(); + const { result } = renderHook(() => useWorkspaceClipboardPacks(options)); + + await act(async () => { + await result.current.handleCopyHandoffPack(); + }); + + expect(options.onShowError).toHaveBeenCalledWith( + "Failed to copy handoff pack", + ); + expect(options.onHandoffCopied).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Draft/useWorkspaceClipboardPacks.ts b/src/components/Draft/useWorkspaceClipboardPacks.ts new file mode 100644 index 0000000..74a8a5c --- /dev/null +++ b/src/components/Draft/useWorkspaceClipboardPacks.ts @@ -0,0 +1,168 @@ +import { useCallback } from "react"; +import { + formatEvidencePackForClipboard, + formatHandoffPackForClipboard, + formatKbDraftForClipboard, +} from "../../features/workspace/workspaceAssistant"; +import type { + CaseIntake, + EvidencePack, + HandoffPack, + KbDraft, +} from "../../types/workspace"; + +interface SaveCaseOutcomeParams { + draft_id: string; + status: string; + outcome_summary: string; + handoff_pack_json: string; + kb_draft_json: string; + evidence_pack_json: string; + tags_json: string; +} + +interface UseWorkspaceClipboardPacksOptions { + handoffPack: HandoffPack; + evidencePack: EvidencePack; + kbDraft: KbDraft; + caseIntake: CaseIntake; + savedDraftId: string | null; + currentTicketId: string | null; + saveCaseOutcome: (params: SaveCaseOutcomeParams) => Promise; + logEvent: (event: string, payload?: Record) => unknown; + onHandoffCopied: () => void; + onShowSuccess: (message: string) => void; + onShowError: (message: string) => void; +} + +export function useWorkspaceClipboardPacks({ + handoffPack, + evidencePack, + kbDraft, + caseIntake, + savedDraftId, + currentTicketId, + saveCaseOutcome, + logEvent, + onHandoffCopied, + onShowSuccess, + onShowError, +}: UseWorkspaceClipboardPacksOptions) { + const handleCopyHandoffPack = useCallback(async () => { + try { + await navigator.clipboard.writeText( + formatHandoffPackForClipboard(handoffPack), + ); + onHandoffCopied(); + if (savedDraftId) { + await saveCaseOutcome({ + draft_id: savedDraftId, + status: "handoff-ready", + outcome_summary: handoffPack.summary, + handoff_pack_json: JSON.stringify(handoffPack), + kb_draft_json: JSON.stringify(kbDraft), + evidence_pack_json: JSON.stringify(evidencePack), + tags_json: JSON.stringify( + [caseIntake.likely_category].filter(Boolean), + ), + }); + } + void logEvent("workspace_handoff_pack_copied", { + ticket_id: currentTicketId, + note_audience: caseIntake.note_audience, + }); + onShowSuccess("Handoff pack copied"); + } catch { + onShowError("Failed to copy handoff pack"); + } + }, [ + handoffPack, + savedDraftId, + saveCaseOutcome, + kbDraft, + evidencePack, + caseIntake.likely_category, + caseIntake.note_audience, + logEvent, + currentTicketId, + onHandoffCopied, + onShowSuccess, + onShowError, + ]); + + const handleCopyEvidencePack = useCallback(async () => { + try { + await navigator.clipboard.writeText( + formatEvidencePackForClipboard(evidencePack), + ); + if (savedDraftId) { + await saveCaseOutcome({ + draft_id: savedDraftId, + status: "evidence-ready", + outcome_summary: evidencePack.summary, + handoff_pack_json: JSON.stringify(handoffPack), + kb_draft_json: JSON.stringify(kbDraft), + evidence_pack_json: JSON.stringify(evidencePack), + tags_json: JSON.stringify(kbDraft.tags), + }); + } + void logEvent("workspace_evidence_pack_copied", { + ticket_id: currentTicketId, + }); + onShowSuccess("Evidence pack copied"); + } catch { + onShowError("Failed to copy evidence pack"); + } + }, [ + evidencePack, + savedDraftId, + saveCaseOutcome, + handoffPack, + kbDraft, + logEvent, + currentTicketId, + onShowSuccess, + onShowError, + ]); + + const handleCopyKbDraft = useCallback(async () => { + try { + await navigator.clipboard.writeText(formatKbDraftForClipboard(kbDraft)); + if (savedDraftId) { + await saveCaseOutcome({ + draft_id: savedDraftId, + status: "kb-promoted", + outcome_summary: kbDraft.summary, + handoff_pack_json: JSON.stringify(handoffPack), + kb_draft_json: JSON.stringify(kbDraft), + evidence_pack_json: JSON.stringify(evidencePack), + tags_json: JSON.stringify(kbDraft.tags), + }); + } + void logEvent("workspace_kb_draft_copied", { + ticket_id: currentTicketId, + category: caseIntake.likely_category, + }); + onShowSuccess("KB draft copied"); + } catch { + onShowError("Failed to copy KB draft"); + } + }, [ + kbDraft, + saveCaseOutcome, + savedDraftId, + handoffPack, + evidencePack, + logEvent, + currentTicketId, + caseIntake.likely_category, + onShowSuccess, + onShowError, + ]); + + return { + handleCopyHandoffPack, + handleCopyEvidencePack, + handleCopyKbDraft, + }; +}