Skip to content

Commit a48161d

Browse files
committed
refactor(components): extract useResponseActions hook
Move copy / export / cancel / response-change / template-apply / save-as-template / template-modal-save out of DraftTab.tsx into a single hook. Hook owns showTemplateModal + templateModalRating state; shell consumes showTemplateModal as a prop into WorkspaceDialogs. handleClear now calls resetResponseActions() instead of touching those setters individually.
1 parent b0869a1 commit a48161d

3 files changed

Lines changed: 419 additions & 134 deletions

File tree

src/components/Draft/DraftTab.tsx

Lines changed: 34 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
useImperativeHandle,
77
useMemo,
88
} from "react";
9-
import { invoke } from "@tauri-apps/api/core";
109
import { DraftResponsePanel } from "./DraftResponsePanel";
1110
import { InputPanel } from "./InputPanel";
1211
import { DiagnosisPanel, TreeResult } from "./DiagnosisPanel";
@@ -18,6 +17,7 @@ import { useDraftGeneration } from "./useDraftGeneration";
1817
import { useDraftIntake } from "./useDraftIntake";
1918
import { useDraftPersistence } from "./useDraftPersistence";
2019
import { useGuidedRunbook } from "./useGuidedRunbook";
20+
import { useResponseActions } from "./useResponseActions";
2121
import { useWorkspaceArtifacts } from "./useWorkspaceArtifacts";
2222
import { useWorkspaceClipboardPacks } from "./useWorkspaceClipboardPacks";
2323
import { ConversationInput } from "./ConversationInput";
@@ -46,10 +46,7 @@ import {
4646
compactLines,
4747
parseCaseIntake,
4848
} from "../../features/workspace/workspaceAssistant";
49-
import {
50-
calculateEditRatio,
51-
countWords,
52-
} from "../../features/analytics/qualityMetrics";
49+
import { countWords } from "../../features/analytics/qualityMetrics";
5350
import type { JiraTicket } from "../../hooks/useJira";
5451
import type {
5552
ConfidenceAssessment,
@@ -339,10 +336,6 @@ export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
339336
} = useAlternatives();
340337
const { suggestions, findSimilar, saveAsTemplate, incrementUsage } =
341338
useSavedResponses();
342-
const [showTemplateModal, setShowTemplateModal] = useState(false);
343-
const [templateModalRating, setTemplateModalRating] = useState<
344-
number | undefined
345-
>(undefined);
346339
const [suggestionsDismissed, setSuggestionsDismissed] = useState(false);
347340

348341
const {
@@ -531,43 +524,37 @@ export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
531524
[savedDraftId],
532525
);
533526

534-
const handleApplyTemplate = useCallback((content: string) => {
535-
setResponse(content);
536-
}, []);
537-
538-
const handleSaveAsTemplate = useCallback((rating: number) => {
539-
setTemplateModalRating(rating);
540-
setShowTemplateModal(true);
541-
}, []);
542-
543-
const handleTemplateModalSave = useCallback(
544-
async (
545-
name: string,
546-
category: string | null,
547-
content: string,
548-
variablesJson: string | null,
549-
): Promise<boolean> => {
550-
const id = await saveAsTemplate(name, content, {
551-
sourceDraftId: savedDraftId ?? undefined,
552-
sourceRating: templateModalRating,
553-
category: category ?? undefined,
554-
variablesJson: variablesJson ?? undefined,
555-
});
556-
if (id) {
557-
showSuccess("Response saved as template");
558-
return true;
559-
}
560-
showError("Failed to save template");
561-
return false;
562-
},
563-
[
564-
saveAsTemplate,
565-
savedDraftId,
566-
templateModalRating,
567-
showSuccess,
568-
showError,
569-
],
570-
);
527+
const {
528+
showTemplateModal,
529+
setShowTemplateModal,
530+
templateModalRating,
531+
handleApplyTemplate,
532+
handleSaveAsTemplate,
533+
handleTemplateModalSave,
534+
handleResponseChange,
535+
handleCancel,
536+
handleCopyResponse,
537+
handleExportResponse,
538+
resetResponseActions,
539+
} = useResponseActions({
540+
response,
541+
originalResponse,
542+
isResponseEdited,
543+
confidence,
544+
sources,
545+
savedDraftId,
546+
streamingText,
547+
cancelGeneration,
548+
saveAsTemplate,
549+
logEvent,
550+
setResponse,
551+
setOriginalResponse,
552+
setIsResponseEdited,
553+
setGenerating,
554+
setHandoffTouched,
555+
onShowSuccess: showSuccess,
556+
onShowError: showError,
557+
});
571558

572559
const handleSuggestionApply = useCallback(
573560
(content: string, templateId: string) => {
@@ -620,8 +607,7 @@ export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
620607
setSavedDraftCreatedAt(null);
621608
setConversationEntries([]);
622609
setHandoffTouched(false);
623-
setShowTemplateModal(false);
624-
setTemplateModalRating(undefined);
610+
resetResponseActions();
625611
setSuggestionsDismissed(false);
626612
setCaseIntake({
627613
...parseCaseIntake(null),
@@ -638,14 +624,6 @@ export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
638624
resetGeneration();
639625
}, [workspacePersonalization.preferred_note_audience, resetGeneration]);
640626

641-
const handleResponseChange = useCallback(
642-
(text: string) => {
643-
setResponse(text);
644-
setIsResponseEdited(text !== originalResponse);
645-
},
646-
[originalResponse],
647-
);
648-
649627
const handleTreeComplete = useCallback((result: TreeResult) => {
650628
setTreeResult(result);
651629
}, []);
@@ -724,17 +702,6 @@ export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
724702
[modelLoaded, responseLength, generateStreaming, clearStreamingText],
725703
);
726704

727-
const handleCancel = useCallback(async () => {
728-
await cancelGeneration();
729-
setGenerating(false);
730-
// Keep the streaming text that was generated so far
731-
if (streamingText) {
732-
setResponse(streamingText);
733-
setOriginalResponse(streamingText);
734-
setIsResponseEdited(false);
735-
}
736-
}, [cancelGeneration, streamingText]);
737-
738705
useEffect(() => {
739706
if (viewMode !== "panels") {
740707
return;
@@ -1191,73 +1158,6 @@ export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
11911158
loadTemplates();
11921159
}, [loadTemplates]);
11931160

1194-
const handleCopyResponse = useCallback(async () => {
1195-
if (!response) return;
1196-
try {
1197-
const mode = confidence?.mode ?? "answer";
1198-
const hasCitations = sources.length > 0;
1199-
const copyAllowed = mode === "answer" && hasCitations;
1200-
1201-
if (!copyAllowed) {
1202-
const reason = window.prompt(
1203-
"Copy override required. This response is missing citations or is not in answer mode.\n\nEnter a reason to proceed (will be logged locally):",
1204-
);
1205-
if (!reason || !reason.trim()) {
1206-
showError("Copy cancelled (reason required).");
1207-
return;
1208-
}
1209-
await invoke("audit_response_copy_override", {
1210-
reason: reason.trim(),
1211-
confidenceMode: confidence?.mode ?? null,
1212-
sourcesCount: sources.length,
1213-
});
1214-
}
1215-
await navigator.clipboard.writeText(response);
1216-
setHandoffTouched(true);
1217-
logEvent("response_copied", {
1218-
draft_id: savedDraftId,
1219-
word_count: countWords(response),
1220-
is_edited: isResponseEdited,
1221-
edit_ratio: Number(
1222-
calculateEditRatio(originalResponse, response).toFixed(3),
1223-
),
1224-
});
1225-
showSuccess("Response copied to clipboard");
1226-
} catch {
1227-
showError("Failed to copy response");
1228-
}
1229-
}, [
1230-
response,
1231-
confidence?.mode,
1232-
sources.length,
1233-
showSuccess,
1234-
showError,
1235-
logEvent,
1236-
savedDraftId,
1237-
isResponseEdited,
1238-
originalResponse,
1239-
setHandoffTouched,
1240-
]);
1241-
1242-
const handleExportResponse = useCallback(async () => {
1243-
if (!response) {
1244-
showError("No response to export");
1245-
return;
1246-
}
1247-
try {
1248-
const saved = await invoke<boolean>("export_draft", {
1249-
responseText: response,
1250-
format: "Markdown",
1251-
});
1252-
if (saved) {
1253-
setHandoffTouched(true);
1254-
showSuccess("Response exported successfully");
1255-
}
1256-
} catch (e) {
1257-
showError(`Export failed: ${e}`);
1258-
}
1259-
}, [response, showSuccess, showError, setHandoffTouched]);
1260-
12611161
// Expose functions to parent via ref
12621162
useImperativeHandle(
12631163
ref,
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// @vitest-environment jsdom
2+
import { act, renderHook } from "@testing-library/react";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const invokeMock = vi.fn();
6+
vi.mock("@tauri-apps/api/core", () => ({
7+
invoke: (command: string, payload?: Record<string, unknown>) =>
8+
invokeMock(command, payload),
9+
}));
10+
11+
import { useResponseActions } from "./useResponseActions";
12+
13+
type HookOptions = Parameters<typeof useResponseActions>[0];
14+
15+
const writeText = vi.fn().mockResolvedValue(undefined);
16+
17+
beforeEach(() => {
18+
writeText.mockClear();
19+
invokeMock.mockReset();
20+
invokeMock.mockResolvedValue(true);
21+
Object.defineProperty(navigator, "clipboard", {
22+
value: { writeText },
23+
configurable: true,
24+
});
25+
});
26+
27+
function makeOptions(overrides: Partial<HookOptions> = {}): HookOptions {
28+
return {
29+
response: "generated text",
30+
originalResponse: "generated text",
31+
isResponseEdited: false,
32+
confidence: { mode: "answer" } as HookOptions["confidence"],
33+
sources: [
34+
{ chunk_id: "s1", title: "Source 1", snippet: "x", score: 1 },
35+
] as unknown as HookOptions["sources"],
36+
savedDraftId: null,
37+
streamingText: "",
38+
39+
cancelGeneration: vi.fn(),
40+
saveAsTemplate: vi.fn().mockResolvedValue("tpl-1"),
41+
logEvent: vi.fn(),
42+
43+
setResponse: vi.fn(),
44+
setOriginalResponse: vi.fn(),
45+
setIsResponseEdited: vi.fn(),
46+
setGenerating: vi.fn(),
47+
setHandoffTouched: vi.fn(),
48+
49+
onShowSuccess: vi.fn(),
50+
onShowError: vi.fn(),
51+
...overrides,
52+
};
53+
}
54+
55+
describe("useResponseActions", () => {
56+
it("copies the response directly when mode is answer and citations exist", async () => {
57+
const options = makeOptions();
58+
const { result } = renderHook(() => useResponseActions(options));
59+
60+
await act(async () => {
61+
await result.current.handleCopyResponse();
62+
});
63+
64+
expect(writeText).toHaveBeenCalledWith("generated text");
65+
expect(invokeMock).not.toHaveBeenCalledWith(
66+
"audit_response_copy_override",
67+
expect.anything(),
68+
);
69+
expect(options.setHandoffTouched).toHaveBeenCalledWith(true);
70+
expect(options.onShowSuccess).toHaveBeenCalledWith(
71+
"Response copied to clipboard",
72+
);
73+
});
74+
75+
it("requires a reason when copy guard would otherwise block", async () => {
76+
const promptSpy = vi
77+
.spyOn(window, "prompt")
78+
.mockReturnValue("ops needs this now");
79+
const options = makeOptions({ sources: [] });
80+
const { result } = renderHook(() => useResponseActions(options));
81+
82+
await act(async () => {
83+
await result.current.handleCopyResponse();
84+
});
85+
86+
expect(promptSpy).toHaveBeenCalled();
87+
expect(invokeMock).toHaveBeenCalledWith("audit_response_copy_override", {
88+
reason: "ops needs this now",
89+
confidenceMode: "answer",
90+
sourcesCount: 0,
91+
});
92+
expect(writeText).toHaveBeenCalled();
93+
});
94+
95+
it("cancels and returns early when the user declines the override prompt", async () => {
96+
vi.spyOn(window, "prompt").mockReturnValue("");
97+
const options = makeOptions({ sources: [] });
98+
const { result } = renderHook(() => useResponseActions(options));
99+
100+
await act(async () => {
101+
await result.current.handleCopyResponse();
102+
});
103+
104+
expect(writeText).not.toHaveBeenCalled();
105+
expect(options.onShowError).toHaveBeenCalledWith(
106+
"Copy cancelled (reason required).",
107+
);
108+
});
109+
110+
it("exports the response and marks handoff touched", async () => {
111+
const options = makeOptions();
112+
const { result } = renderHook(() => useResponseActions(options));
113+
114+
await act(async () => {
115+
await result.current.handleExportResponse();
116+
});
117+
118+
expect(invokeMock).toHaveBeenCalledWith("export_draft", {
119+
responseText: "generated text",
120+
format: "Markdown",
121+
});
122+
expect(options.setHandoffTouched).toHaveBeenCalledWith(true);
123+
expect(options.onShowSuccess).toHaveBeenCalledWith(
124+
"Response exported successfully",
125+
);
126+
});
127+
128+
it("keeps partial streaming text on cancel", async () => {
129+
const options = makeOptions({ streamingText: "partial..." });
130+
const { result } = renderHook(() => useResponseActions(options));
131+
132+
await act(async () => {
133+
await result.current.handleCancel();
134+
});
135+
136+
expect(options.cancelGeneration).toHaveBeenCalled();
137+
expect(options.setGenerating).toHaveBeenCalledWith(false);
138+
expect(options.setResponse).toHaveBeenCalledWith("partial...");
139+
expect(options.setOriginalResponse).toHaveBeenCalledWith("partial...");
140+
expect(options.setIsResponseEdited).toHaveBeenCalledWith(false);
141+
});
142+
143+
it("flags edited when response changes from the original", () => {
144+
const options = makeOptions({ originalResponse: "orig" });
145+
const { result } = renderHook(() => useResponseActions(options));
146+
147+
act(() => {
148+
result.current.handleResponseChange("new text");
149+
});
150+
151+
expect(options.setResponse).toHaveBeenCalledWith("new text");
152+
expect(options.setIsResponseEdited).toHaveBeenCalledWith(true);
153+
});
154+
155+
it("opens the template modal and stores the rating when saveAsTemplate is called", () => {
156+
const options = makeOptions();
157+
const { result } = renderHook(() => useResponseActions(options));
158+
159+
act(() => {
160+
result.current.handleSaveAsTemplate(4);
161+
});
162+
163+
expect(result.current.showTemplateModal).toBe(true);
164+
expect(result.current.templateModalRating).toBe(4);
165+
});
166+
});

0 commit comments

Comments
 (0)