Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 34 additions & 134 deletions src/components/Draft/DraftTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
useImperativeHandle,
useMemo,
} from "react";
import { invoke } from "@tauri-apps/api/core";
import { DraftResponsePanel } from "./DraftResponsePanel";
import { InputPanel } from "./InputPanel";
import { DiagnosisPanel, TreeResult } from "./DiagnosisPanel";
Expand All @@ -18,6 +17,7 @@
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";
Expand Down Expand Up @@ -46,10 +46,7 @@
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,
Expand Down Expand Up @@ -339,10 +336,6 @@
} = 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 {
Expand Down Expand Up @@ -528,46 +521,40 @@
return next;
});
},
[savedDraftId],

Check warning on line 524 in src/components/Draft/DraftTab.tsx

View workflow job for this annotation

GitHub Actions / quality-gates

React Hook useCallback has a missing dependency: 'setCaseIntake'. Either include it or remove the dependency array
);

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<boolean> => {
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) => {
Expand Down Expand Up @@ -620,8 +607,7 @@
setSavedDraftCreatedAt(null);
setConversationEntries([]);
setHandoffTouched(false);
setShowTemplateModal(false);
setTemplateModalRating(undefined);
resetResponseActions();
setSuggestionsDismissed(false);
setCaseIntake({
...parseCaseIntake(null),
Expand All @@ -636,16 +622,8 @@
setPendingSimilarCaseOpen(null);
setWorkspaceRunbookScopeKey(createWorkspaceRunbookScopeKey());
resetGeneration();
}, [workspacePersonalization.preferred_note_audience, resetGeneration]);

Check warning on line 625 in src/components/Draft/DraftTab.tsx

View workflow job for this annotation

GitHub Actions / quality-gates

React Hook useCallback has missing dependencies: 'resetApproval', 'resetChecklist', 'resetFirstResponse', 'resetResponseActions', 'resetWorkspaceArtifacts', 'setCaseIntake', 'setGuidedRunbookNote', 'setGuidedRunbookSession', 'setPendingSimilarCaseOpen', 'setRunbookSessionSourceScopeKey', and 'setRunbookSessionTouched'. Either include them or remove the dependency array

const handleResponseChange = useCallback(
(text: string) => {
setResponse(text);
setIsResponseEdited(text !== originalResponse);
},
[originalResponse],
);

const handleTreeComplete = useCallback((result: TreeResult) => {
setTreeResult(result);
}, []);
Expand Down Expand Up @@ -721,20 +699,9 @@
setGenerating(false);
}
},
[modelLoaded, responseLength, generateStreaming, clearStreamingText],

Check warning on line 702 in src/components/Draft/DraftTab.tsx

View workflow job for this annotation

GitHub Actions / quality-gates

React Hook useCallback has a missing dependency: 'setGenerating'. Either include it or remove the dependency array
);

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;
Expand Down Expand Up @@ -1108,7 +1075,7 @@

void handleCopyKbDraft();
},
[

Check warning on line 1078 in src/components/Draft/DraftTab.tsx

View workflow job for this annotation

GitHub Actions / quality-gates

React Hook useCallback has missing dependencies: 'setApprovalQuery' and 'setCaseIntake'. Either include them or remove the dependency array
logEvent,
currentTicketId,
handleGenerate,
Expand Down Expand Up @@ -1191,73 +1158,6 @@
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<boolean>("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,
Expand Down
166 changes: 166 additions & 0 deletions src/components/Draft/useResponseActions.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) =>
invokeMock(command, payload),
}));

import { useResponseActions } from "./useResponseActions";

type HookOptions = Parameters<typeof useResponseActions>[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> = {}): 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);
});
});
Loading
Loading