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
86 changes: 14 additions & 72 deletions src/components/Draft/DraftTab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
useState,
useCallback,
useEffect,
forwardRef,
useImperativeHandle,
useMemo,
Expand All @@ -15,6 +14,7 @@
import { useDraftFirstResponse } from "./useDraftFirstResponse";
import { useDraftGeneration } from "./useDraftGeneration";
import { useDraftIntake } from "./useDraftIntake";
import { useDraftLifecycle } from "./useDraftLifecycle";
import { useDraftPersistence } from "./useDraftPersistence";
import { useGuidedRunbook } from "./useGuidedRunbook";
import { useResponseActions } from "./useResponseActions";
Expand Down Expand Up @@ -177,19 +177,6 @@
return `workspace:${createWorkspaceScopeSeed()}`;
}

function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
const tag = target.tagName.toLowerCase();
return (
tag === "input" ||
tag === "textarea" ||
tag === "select" ||
target.isContentEditable
);
}

export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
function DraftTab(
{ initialDraft, onNavigateToSource, revampModeEnabled = false },
Expand Down Expand Up @@ -448,10 +435,6 @@
},
});

useEffect(() => {
void refreshWorkspaceCatalog();
}, [refreshWorkspaceCatalog]);

const {
caseIntake,
setCaseIntake,
Expand Down Expand Up @@ -521,7 +504,7 @@
return next;
});
},
[savedDraftId],

Check warning on line 507 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 {
Expand Down Expand Up @@ -571,21 +554,6 @@
setSuggestionsDismissed(true);
}, []);

// Find similar saved responses when input changes
useEffect(() => {
if (input.trim().length >= 10) {
setSuggestionsDismissed(false);
findSimilar(input);
}
}, [input, findSimilar]);

// Load alternatives when draft is loaded/saved
useEffect(() => {
if (savedDraftId) {
loadAlternatives(savedDraftId);
}
}, [savedDraftId, loadAlternatives]);

const handleClear = useCallback(() => {
setInput("");
setOcrText(null);
Expand Down Expand Up @@ -622,7 +590,7 @@
setPendingSimilarCaseOpen(null);
setWorkspaceRunbookScopeKey(createWorkspaceRunbookScopeKey());
resetGeneration();
}, [workspacePersonalization.preferred_note_audience, resetGeneration]);

Check warning on line 593 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 handleTreeComplete = useCallback((result: TreeResult) => {
setTreeResult(result);
Expand Down Expand Up @@ -699,37 +667,9 @@
setGenerating(false);
}
},
[modelLoaded, responseLength, generateStreaming, clearStreamingText],

Check warning on line 670 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
);

useEffect(() => {
if (viewMode !== "panels") {
return;
}
const handleKeydown = (event: KeyboardEvent) => {
if (!event.metaKey || event.altKey || event.ctrlKey) {
return;
}
if (isEditableTarget(event.target)) {
return;
}

if (event.key === "1") {
event.preventDefault();
handlePanelDensityModeChange("balanced");
} else if (event.key === "2") {
event.preventDefault();
handlePanelDensityModeChange("focus-intake");
} else if (event.key === "3") {
event.preventDefault();
handlePanelDensityModeChange("focus-response");
}
};

window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [viewMode, handlePanelDensityModeChange]);

const buildDiagnosisJson = useCallback(() => {
const completedIds = Object.keys(checklistCompleted).filter(
(id) => checklistCompleted[id],
Expand Down Expand Up @@ -1075,7 +1015,7 @@

void handleCopyKbDraft();
},
[

Check warning on line 1018 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 @@ -1146,17 +1086,19 @@
onShowError: showError,
});

// Load initial draft if provided
useEffect(() => {
if (initialDraft) {
handleLoadDraft(initialDraft);
}
}, [initialDraft, handleLoadDraft]);

// Load templates on mount
useEffect(() => {
loadTemplates();
}, [loadTemplates]);
useDraftLifecycle({
initialDraft,
viewMode,
input,
savedDraftId,
refreshWorkspaceCatalog,
findSimilar,
loadAlternatives,
loadTemplates,
handleLoadDraft,
onPanelDensityModeChange: handlePanelDensityModeChange,
setSuggestionsDismissed,
});

// Expose functions to parent via ref
useImperativeHandle(
Expand Down
119 changes: 119 additions & 0 deletions src/components/Draft/useDraftLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// @vitest-environment jsdom
import { fireEvent, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useDraftLifecycle } from "./useDraftLifecycle";

type HookOptions = Parameters<typeof useDraftLifecycle>[0];

function makeOptions(overrides: Partial<HookOptions> = {}): HookOptions {
return {
initialDraft: null,
viewMode: "panels",
input: "",
savedDraftId: null,
refreshWorkspaceCatalog: vi.fn().mockResolvedValue(undefined),
findSimilar: vi.fn().mockResolvedValue(undefined),
loadAlternatives: vi.fn().mockResolvedValue(undefined),
loadTemplates: vi.fn().mockResolvedValue(undefined),
handleLoadDraft: vi.fn(),
onPanelDensityModeChange: vi.fn(),
setSuggestionsDismissed: vi.fn(),
...overrides,
};
}

describe("useDraftLifecycle", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("refreshes catalog and loads templates on mount", () => {
const options = makeOptions();
renderHook(() => useDraftLifecycle(options));

expect(options.refreshWorkspaceCatalog).toHaveBeenCalledTimes(1);
expect(options.loadTemplates).toHaveBeenCalledTimes(1);
});

it("finds similar saved responses once input has 10+ characters", () => {
const options = makeOptions({ input: "short" });
const { rerender } = renderHook(
(props: HookOptions) => useDraftLifecycle(props),
{ initialProps: options },
);
expect(options.findSimilar).not.toHaveBeenCalled();

const updated = makeOptions({
...options,
input: "longer than ten characters",
});
rerender(updated);

expect(updated.findSimilar).toHaveBeenCalledWith(
"longer than ten characters",
);
expect(updated.setSuggestionsDismissed).toHaveBeenCalledWith(false);
});

it("loads alternatives when a savedDraftId appears", () => {
const options = makeOptions();
const { rerender } = renderHook(
(props: HookOptions) => useDraftLifecycle(props),
{ initialProps: options },
);
expect(options.loadAlternatives).not.toHaveBeenCalled();

const updated = makeOptions({ ...options, savedDraftId: "draft-1" });
rerender(updated);

expect(updated.loadAlternatives).toHaveBeenCalledWith("draft-1");
});

it("fires initialDraft load exactly once when the prop is provided", () => {
const initialDraft = { id: "d-1" } as HookOptions["initialDraft"];
const options = makeOptions({ initialDraft });
renderHook(() => useDraftLifecycle(options));

expect(options.handleLoadDraft).toHaveBeenCalledWith(initialDraft);
});

it("maps Cmd-1/2/3 to the three panel density modes in panels view", () => {
const options = makeOptions({ viewMode: "panels" });
renderHook(() => useDraftLifecycle(options));

fireEvent.keyDown(window, { key: "1", metaKey: true });
expect(options.onPanelDensityModeChange).toHaveBeenCalledWith("balanced");

fireEvent.keyDown(window, { key: "2", metaKey: true });
expect(options.onPanelDensityModeChange).toHaveBeenCalledWith(
"focus-intake",
);

fireEvent.keyDown(window, { key: "3", metaKey: true });
expect(options.onPanelDensityModeChange).toHaveBeenCalledWith(
"focus-response",
);
});

it("ignores keyboard shortcuts when viewMode is conversation", () => {
const options = makeOptions({ viewMode: "conversation" });
renderHook(() => useDraftLifecycle(options));

fireEvent.keyDown(window, { key: "1", metaKey: true });
expect(options.onPanelDensityModeChange).not.toHaveBeenCalled();
});

it("ignores keyboard shortcuts when focus is in an editable target", () => {
const options = makeOptions({ viewMode: "panels" });
renderHook(() => useDraftLifecycle(options));

const input = document.createElement("input");
document.body.appendChild(input);
input.focus();

fireEvent.keyDown(input, { key: "1", metaKey: true, bubbles: true });
expect(options.onPanelDensityModeChange).not.toHaveBeenCalled();

document.body.removeChild(input);
});
});
102 changes: 102 additions & 0 deletions src/components/Draft/useDraftLifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useEffect } from "react";
import type { SavedDraft } from "../../types/workspace";

function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
const tag = target.tagName.toLowerCase();
return (
tag === "input" ||
tag === "textarea" ||
tag === "select" ||
target.isContentEditable
);
}

type PanelDensityMode = "balanced" | "focus-intake" | "focus-response";
type ViewMode = "panels" | "conversation";

interface UseDraftLifecycleOptions {
initialDraft?: SavedDraft | null;
viewMode: ViewMode;
input: string;
savedDraftId: string | null;

refreshWorkspaceCatalog: () => Promise<unknown>;
findSimilar: (query: string) => Promise<unknown> | void;
loadAlternatives: (draftId: string) => Promise<unknown> | void;
loadTemplates: () => Promise<unknown> | void;
handleLoadDraft: (draft: SavedDraft) => void;
onPanelDensityModeChange: (mode: PanelDensityMode) => void;
setSuggestionsDismissed: (value: boolean) => void;
}

export function useDraftLifecycle({
initialDraft,
viewMode,
input,
savedDraftId,
refreshWorkspaceCatalog,
findSimilar,
loadAlternatives,
loadTemplates,
handleLoadDraft,
onPanelDensityModeChange,
setSuggestionsDismissed,
}: UseDraftLifecycleOptions) {
useEffect(() => {
void refreshWorkspaceCatalog();
}, [refreshWorkspaceCatalog]);

useEffect(() => {
if (input.trim().length >= 10) {
setSuggestionsDismissed(false);
void findSimilar(input);
}
}, [input, findSimilar, setSuggestionsDismissed]);

useEffect(() => {
if (savedDraftId) {
void loadAlternatives(savedDraftId);
}
}, [savedDraftId, loadAlternatives]);

useEffect(() => {
if (viewMode !== "panels") {
return;
}
const handleKeydown = (event: KeyboardEvent) => {
if (!event.metaKey || event.altKey || event.ctrlKey) {
return;
}
if (isEditableTarget(event.target)) {
return;
}

if (event.key === "1") {
event.preventDefault();
onPanelDensityModeChange("balanced");
} else if (event.key === "2") {
event.preventDefault();
onPanelDensityModeChange("focus-intake");
} else if (event.key === "3") {
event.preventDefault();
onPanelDensityModeChange("focus-response");
}
};

window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [viewMode, onPanelDensityModeChange]);

useEffect(() => {
if (initialDraft) {
handleLoadDraft(initialDraft);
}
}, [initialDraft, handleLoadDraft]);

useEffect(() => {
void loadTemplates();
}, [loadTemplates]);
}
Loading