From 1ba82e06ea7923aec14e43fb42b7becec594fad0 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 22 Jun 2026 14:40:19 -0400 Subject: [PATCH 1/7] Fix broken new-interlinear-project --- src/components/modals/ProjectModals.tsx | 73 +++++++++++++++++++------ 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/src/components/modals/ProjectModals.tsx b/src/components/modals/ProjectModals.tsx index dce22496..f7071250 100644 --- a/src/components/modals/ProjectModals.tsx +++ b/src/components/modals/ProjectModals.tsx @@ -41,8 +41,9 @@ type PendingReplace = * on Save As. * @param props.loadFromProject - Loads a project's analysis + config into the draft (the "Open" * flow). - * @param props.newDraft - Starts a fresh, empty draft (the "New" flow); no backend project is - * created until Save As. + * @param props.newDraft - Seeds the in-memory draft with empty analysis and the given config; + * called by {@link createAndPersistProject} before the backend round-trip so the editor is ready + * regardless of whether persistence succeeds. * @param props.markSynced - Marks the draft as saved (clears `dirty`) after a successful Save As, * given the analysis that was persisted; a no-op if an edit landed during the save. * @param props.modal - Which modal is currently open. @@ -108,6 +109,12 @@ export default function ProjectModals({ /** A draft-replacing action awaiting confirmation because the draft has unsaved changes. */ const [pendingReplace, setPendingReplace] = useState(undefined); + /** + * Whether the project-creation round-trip is in flight. Used to disable the Create / Cancel + * buttons in the create modal while the backend persists the new project. + */ + const [isCreating, setIsCreating] = useState(false); + /** * Whether the discard-and-replace confirmation flow is in flight (either opening an existing * project or starting a new draft); disables DiscardDraftConfirm buttons to prevent races. @@ -200,25 +207,49 @@ export default function ProjectModals({ ); /** - * Starts a fresh, empty draft with the given config — the "New" flow. Seeds the draft (carrying - * the typed name/description as the Save As prefill), clears the active project so there is no - * Save target yet — Save routes to Save As until the draft is saved — and dismisses the modal. No - * backend project is created until the user explicitly saves. + * Creates a new project in storage with the given config and an empty analysis, then seeds the + * draft from it so Save targets the newly created project. This is the "New" flow: the project is + * persisted immediately so it shows up in "Select Interlinear Project" right away. On failure the + * backend has already sent an error notification; here we only log and clear the active project. * * @param config - The configuration collected by the New dialog. + * @returns A promise that resolves once the project is created (or the failure is handled). */ - const startNewDraft = useCallback( - (config: CreateDraftConfig) => { + const createAndPersistProject = useCallback( + async (config: CreateDraftConfig) => { newDraft({ analysisLanguages: config.analysisLanguages, ...(config.name !== undefined && { suggestedName: config.name }), ...(config.description !== undefined && { suggestedDescription: config.description }), }); - resetActiveProject(); + try { + const createdJson = await papi.commands.sendCommand( + 'interlinearizer.createProject', + projectId, + config.analysisLanguages, + undefined, + config.name, + config.description, + ); + const created: unknown = JSON.parse(createdJson); + if (!isInterlinearProjectSummary(created)) { + await papi.notifications + .send({ message: '%interlinearizer_error_create_project_failed%', severity: 'error' }) + .catch(() => {}); + resetActiveProject(); + setCreateSourceIsSelect(false); + setModal('none'); + return; + } + setActiveProject(created); + } catch (e) { + logger.error('Interlinearizer: failed to create project from New dialog', e); + resetActiveProject(); + } setCreateSourceIsSelect(false); setModal('none'); }, - [newDraft, resetActiveProject, setModal], + [newDraft, projectId, resetActiveProject, setActiveProject, setModal], ); /** @@ -236,21 +267,26 @@ export default function ProjectModals({ ); /** - * Called when the New dialog is submitted. Starts the new draft immediately, or defers behind the - * unsaved-changes confirmation when the draft is dirty. Starting a draft is synchronous (no - * backend round-trip), so no in-flight re-entry guard is needed. + * Called when the New dialog is submitted. Creates and persists the project immediately, or + * defers behind the unsaved-changes confirmation when the draft is dirty. Disables the modal + * buttons via `isCreating` during the backend round-trip. * * @param config - The configuration collected by the New dialog. */ const handleCreateDraft = useCallback( - (config: CreateDraftConfig) => { + async (config: CreateDraftConfig) => { if (dirty) { setPendingReplace({ kind: 'new', config }); return; } - startNewDraft(config); + setIsCreating(true); + try { + await createAndPersistProject(config); + } finally { + setIsCreating(false); + } }, - [dirty, startNewDraft], + [createAndPersistProject, dirty], ); /** Confirms the deferred draft-replacing action after the user accepts losing unsaved changes. */ @@ -266,13 +302,13 @@ export default function ProjectModals({ if (pendingReplace.kind === 'open') { await openProject(pendingReplace.project); } else { - startNewDraft(pendingReplace.config); + await createAndPersistProject(pendingReplace.config); } } finally { setIsReplacing(false); setPendingReplace(undefined); } - }, [isReplacing, openProject, pendingReplace, startNewDraft]); + }, [createAndPersistProject, isReplacing, openProject, pendingReplace]); /** Cancels the deferred action, returning to the underlying modal with the draft untouched. */ const handleCancelReplace = useCallback(() => setPendingReplace(undefined), []); @@ -426,6 +462,7 @@ export default function ProjectModals({ {modal === 'create' && ( From d52a966989f82241da5f195c9e1580e2cc0f9fe6 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 22 Jun 2026 15:37:12 -0400 Subject: [PATCH 2/7] Address code review feedback on fix-create-proj - Restructure createAndPersistProject to eliminate duplicate setCreateSourceIsSelect/setModal calls via a single exit path - Set isCreating during the discard-confirm createAndPersistProject path so the create modal buttons are disabled in that flow too - Update stale JSDoc: PendingReplace comment and component summary now accurately describe the immediate-persist New behavior - Split the old New-flow test into success and failure cases so resetActiveProject is asserted for the right reason in each - Add validation-failure test (parse succeeds, non-project shape) to cover lines 242-245 and restore 100% coverage - Fix no-type-assertion lint error in makeWebViewStateWithActiveProjectSpies - Update discard-confirm test to assert createProject is called after confirm Co-Authored-By: Claude Sonnet 4.6 --- .../components/modals/ProjectModals.test.tsx | 122 ++++++++++++++++-- src/components/modals/ProjectModals.tsx | 34 +++-- 2 files changed, 132 insertions(+), 24 deletions(-) diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index 8f168782..be3d0ea4 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -292,6 +292,25 @@ function makeWebViewStateWithResetSpy(resetActiveProject: () => void) { ]; } +/** + * Builds a `useWebViewState` stub with spies on both the setter and the reset for the + * `'activeProject'` key, so tests can assert which path was taken after a create attempt. + * + * @param setActiveProject - Spy invoked when the `'activeProject'` slot is set. + * @param resetActiveProject - Spy invoked when the `'activeProject'` slot is reset. + * @returns A `useWebViewState`-shaped hook stub. + */ +function makeWebViewStateWithActiveProjectSpies( + setActiveProject: jest.Mock, + resetActiveProject: jest.Mock, +) { + return (key: string, defaultValue: T): [T, (v: T) => void, () => void] => [ + defaultValue, + key === 'activeProject' ? setActiveProject : () => {}, + key === 'activeProject' ? resetActiveProject : () => {}, + ]; +} + describe('ProjectModals', () => { beforeEach(() => { jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); @@ -452,7 +471,48 @@ describe('ProjectModals', () => { }); describe('new (create) flow', () => { - it('starts an empty draft, clears the active project, and closes when not dirty', async () => { + it('seeds the draft, calls createProject on the backend, and closes', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); + const newDraft = jest.fn(); + const setModal = jest.fn(); + const setActiveProject = jest.fn(); + const resetActiveProject = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByTestId('create-submit')); + + await waitFor(() => expect(setActiveProject).toHaveBeenCalledWith(MOCK_PROJECT)); + expect(newDraft).toHaveBeenCalledWith({ + analysisLanguages: ['en'], + suggestedName: 'New', + suggestedDescription: 'Desc', + }); + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.createProject', + 'source-proj', + ['en'], + undefined, + 'New', + 'Desc', + ); + expect(resetActiveProject).not.toHaveBeenCalled(); + expect(setModal).toHaveBeenCalledWith('none'); + }); + + it('falls back to resetActiveProject and closes when backend project creation fails', async () => { + // Default mock returns undefined; JSON.parse(undefined) throws into the catch block. const newDraft = jest.fn(); const setModal = jest.fn(); const resetActiveProject = jest.fn(); @@ -469,23 +529,45 @@ describe('ProjectModals', () => { await userEvent.click(screen.getByTestId('create-submit')); - // New starts a draft locally (carrying the typed name/description for the Save As prefill) and - // clears the active project so Save routes to Save As; no backend project is created. + await waitFor(() => expect(setModal).toHaveBeenCalledWith('none')); expect(newDraft).toHaveBeenCalledWith({ analysisLanguages: ['en'], suggestedName: 'New', suggestedDescription: 'Desc', }); - expect(papi.commands.sendCommand).not.toHaveBeenCalledWith( + expect(papi.commands.sendCommand).toHaveBeenCalledWith( 'interlinearizer.createProject', - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), + 'source-proj', + ['en'], + undefined, + 'New', + 'Desc', ); expect(resetActiveProject).toHaveBeenCalledTimes(1); - expect(setModal).toHaveBeenCalledWith('none'); + }); + + it('notifies and falls back to resetActiveProject when backend returns a non-project shape', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify({ bad: true })); + const setModal = jest.fn(); + const resetActiveProject = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByTestId('create-submit')); + + await waitFor(() => expect(setModal).toHaveBeenCalledWith('none')); + expect(papi.notifications.send).toHaveBeenCalledWith({ + message: '%interlinearizer_error_create_project_failed%', + severity: 'error', + }); + expect(resetActiveProject).toHaveBeenCalledTimes(1); }); it('calls setModal with none when the create modal closes without a select source', async () => { @@ -540,14 +622,22 @@ describe('ProjectModals', () => { expect(loadFromProject).not.toHaveBeenCalled(); }); - it('confirms before starting a new draft when the draft is dirty', async () => { + it('confirms before creating a project when the draft is dirty', async () => { const newDraft = jest.fn(); render(); await userEvent.click(screen.getByTestId('create-submit')); expect(screen.getByTestId('discard-modal')).toBeInTheDocument(); - // The new draft must not start until the user confirms discarding the current one. + // Neither draft nor project creation should start until the user confirms discarding. expect(newDraft).not.toHaveBeenCalled(); + expect(papi.commands.sendCommand).not.toHaveBeenCalledWith( + 'interlinearizer.createProject', + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ); await userEvent.click(screen.getByTestId('discard-confirm')); await waitFor(() => @@ -557,6 +647,14 @@ describe('ProjectModals', () => { suggestedDescription: 'Desc', }), ); + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.createProject', + 'source-proj', + ['en'], + undefined, + 'New', + 'Desc', + ); }); it('disables the discard-confirm button while an open is in flight', async () => { diff --git a/src/components/modals/ProjectModals.tsx b/src/components/modals/ProjectModals.tsx index f7071250..24d59b1e 100644 --- a/src/components/modals/ProjectModals.tsx +++ b/src/components/modals/ProjectModals.tsx @@ -15,8 +15,9 @@ import { SelectInterlinearProjectModal } from './SelectInterlinearProjectModal'; export type ModalState = 'none' | 'select' | 'create' | 'metadata' | 'saveAs'; /** - * A draft-replacing action deferred behind the unsaved-changes confirmation: either starting a new - * empty draft or opening an existing project into the draft. + * A draft-replacing action deferred behind the unsaved-changes confirmation: either creating and + * persisting a new project (then seeding the draft from it), or opening an existing project into + * the draft. */ type PendingReplace = | { kind: 'new'; config: CreateDraftConfig } @@ -27,8 +28,10 @@ type PendingReplace = * {@link SelectInterlinearProjectModal}, {@link CreateProjectModal}, {@link ProjectMetadataModal}, or * {@link SaveAsProjectModal}, with the {@link DiscardDraftConfirm} guard overlaid on top when a * draft-replacing action is pending (so canceling returns to the underlying modal with its state - * intact); manages the shared WebView state for the active project; and routes New / Open / Save As - * through the draft (rather than persisting projects directly on every edit). + * intact); manages the shared WebView state for the active project; and routes project lifecycle + * actions through the draft: New creates and persists a project immediately (so it appears in + * Select right away) and seeds the draft from it; Open loads an existing project into the draft; + * Save / Save As write the draft's analysis back to the active project or create a new one. * * @param props - Component props * @param props.activeProject - The currently active interlinear project (the Save target), read @@ -222,6 +225,7 @@ export default function ProjectModals({ ...(config.name !== undefined && { suggestedName: config.name }), ...(config.description !== undefined && { suggestedDescription: config.description }), }); + let created: InterlinearProjectSummary | undefined; try { const createdJson = await papi.commands.sendCommand( 'interlinearizer.createProject', @@ -231,19 +235,20 @@ export default function ProjectModals({ config.name, config.description, ); - const created: unknown = JSON.parse(createdJson); - if (!isInterlinearProjectSummary(created)) { + const parsed: unknown = JSON.parse(createdJson); + if (isInterlinearProjectSummary(parsed)) { + created = parsed; + } else { await papi.notifications .send({ message: '%interlinearizer_error_create_project_failed%', severity: 'error' }) .catch(() => {}); - resetActiveProject(); - setCreateSourceIsSelect(false); - setModal('none'); - return; } - setActiveProject(created); } catch (e) { logger.error('Interlinearizer: failed to create project from New dialog', e); + } + if (created) { + setActiveProject(created); + } else { resetActiveProject(); } setCreateSourceIsSelect(false); @@ -302,7 +307,12 @@ export default function ProjectModals({ if (pendingReplace.kind === 'open') { await openProject(pendingReplace.project); } else { - await createAndPersistProject(pendingReplace.config); + setIsCreating(true); + try { + await createAndPersistProject(pendingReplace.config); + } finally { + setIsCreating(false); + } } } finally { setIsReplacing(false); From 63f2a0ffff261957efad70150b9c5dc5460fcb5a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 22 Jun 2026 15:51:45 -0400 Subject: [PATCH 3/7] Send error notification on all createAndPersistProject failure paths Matches the openProject convention: the catch block now sends the same notification as the validation-failure path, so transport-level rejections (where the backend may not have reached its own notification send) also surface visible feedback to the user. Expanded the JSDoc to document why the pre-round-trip newDraft() call is safe: dirty=false means all draft data is committed to the active project (or the draft was empty), and dirty=true means the user has already confirmed discarding via DiscardDraftConfirm. Co-Authored-By: Claude Sonnet 4.6 --- .../components/modals/ProjectModals.test.tsx | 4 ++++ src/components/modals/ProjectModals.tsx | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index be3d0ea4..2c72e6c3 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -543,6 +543,10 @@ describe('ProjectModals', () => { 'New', 'Desc', ); + expect(papi.notifications.send).toHaveBeenCalledWith({ + message: '%interlinearizer_error_create_project_failed%', + severity: 'error', + }); expect(resetActiveProject).toHaveBeenCalledTimes(1); }); diff --git a/src/components/modals/ProjectModals.tsx b/src/components/modals/ProjectModals.tsx index 24d59b1e..52fe7cd8 100644 --- a/src/components/modals/ProjectModals.tsx +++ b/src/components/modals/ProjectModals.tsx @@ -212,8 +212,17 @@ export default function ProjectModals({ /** * Creates a new project in storage with the given config and an empty analysis, then seeds the * draft from it so Save targets the newly created project. This is the "New" flow: the project is - * persisted immediately so it shows up in "Select Interlinear Project" right away. On failure the - * backend has already sent an error notification; here we only log and clear the active project. + * persisted immediately so it shows up in "Select Interlinear Project" right away. + * + * `newDraft` is called synchronously before the backend round-trip so the editor is ready + * immediately regardless of whether persistence succeeds. This is safe: when dirty is `false` (no + * discard confirmation shown), any data in the draft is either already committed to the active + * project or the draft was empty — nothing is lost. When dirty is `true` the + * {@link DiscardDraftConfirm} dialog has already obtained explicit user consent to discard. + * + * Sends an error notification on any failure path (matching the convention in {@link openProject}) + * so the user always gets feedback even when the backend error notification does not reach the + * frontend (e.g. a transport-level rejection). * * @param config - The configuration collected by the New dialog. * @returns A promise that resolves once the project is created (or the failure is handled). @@ -245,6 +254,9 @@ export default function ProjectModals({ } } catch (e) { logger.error('Interlinearizer: failed to create project from New dialog', e); + await papi.notifications + .send({ message: '%interlinearizer_error_create_project_failed%', severity: 'error' }) + .catch(() => {}); } if (created) { setActiveProject(created); From 1178d8b211d657222e509f25e3b0a87a7c8f5c11 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 22 Jun 2026 17:21:35 -0400 Subject: [PATCH 4/7] Fix duplicate notification and keep create modal open on failure - Remove papi.notifications.send from createAndPersistProject catch block; interlinearizer.createProject already sends its own notification before rethrowing, so the second call caused a duplicate toast (handleSaveAsNew follows the same convention) - createAndPersistProject now returns boolean success; callers own the modal transition so the create modal stays open on failure, preserving the user name/description/language inputs for retry - useDraftProject.ts newDraft JSDoc updated to reflect that the caller is responsible for immediately persisting to the backend Co-Authored-By: Claude Sonnet 4.6 --- .../components/modals/ProjectModals.test.tsx | 16 +++--- src/components/modals/ProjectModals.tsx | 53 +++++++++++++------ src/hooks/useDraftProject.ts | 6 +-- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index 2c72e6c3..d3c4c53a 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -511,7 +511,7 @@ describe('ProjectModals', () => { expect(setModal).toHaveBeenCalledWith('none'); }); - it('falls back to resetActiveProject and closes when backend project creation fails', async () => { + it('falls back to resetActiveProject and keeps modal open when backend project creation fails', async () => { // Default mock returns undefined; JSON.parse(undefined) throws into the catch block. const newDraft = jest.fn(); const setModal = jest.fn(); @@ -529,7 +529,7 @@ describe('ProjectModals', () => { await userEvent.click(screen.getByTestId('create-submit')); - await waitFor(() => expect(setModal).toHaveBeenCalledWith('none')); + await waitFor(() => expect(resetActiveProject).toHaveBeenCalledTimes(1)); expect(newDraft).toHaveBeenCalledWith({ analysisLanguages: ['en'], suggestedName: 'New', @@ -543,14 +543,10 @@ describe('ProjectModals', () => { 'New', 'Desc', ); - expect(papi.notifications.send).toHaveBeenCalledWith({ - message: '%interlinearizer_error_create_project_failed%', - severity: 'error', - }); - expect(resetActiveProject).toHaveBeenCalledTimes(1); + expect(setModal).not.toHaveBeenCalledWith('none'); }); - it('notifies and falls back to resetActiveProject when backend returns a non-project shape', async () => { + it('notifies and falls back to resetActiveProject and keeps modal open when backend returns a non-project shape', async () => { jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify({ bad: true })); const setModal = jest.fn(); const resetActiveProject = jest.fn(); @@ -566,12 +562,12 @@ describe('ProjectModals', () => { await userEvent.click(screen.getByTestId('create-submit')); - await waitFor(() => expect(setModal).toHaveBeenCalledWith('none')); + await waitFor(() => expect(resetActiveProject).toHaveBeenCalledTimes(1)); expect(papi.notifications.send).toHaveBeenCalledWith({ message: '%interlinearizer_error_create_project_failed%', severity: 'error', }); - expect(resetActiveProject).toHaveBeenCalledTimes(1); + expect(setModal).not.toHaveBeenCalledWith('none'); }); it('calls setModal with none when the create modal closes without a select source', async () => { diff --git a/src/components/modals/ProjectModals.tsx b/src/components/modals/ProjectModals.tsx index 52fe7cd8..3096d68d 100644 --- a/src/components/modals/ProjectModals.tsx +++ b/src/components/modals/ProjectModals.tsx @@ -220,15 +220,17 @@ export default function ProjectModals({ * project or the draft was empty — nothing is lost. When dirty is `true` the * {@link DiscardDraftConfirm} dialog has already obtained explicit user consent to discard. * - * Sends an error notification on any failure path (matching the convention in {@link openProject}) - * so the user always gets feedback even when the backend error notification does not reach the - * frontend (e.g. a transport-level rejection). + * The `interlinearizer.createProject` command sends its own error notification before rethrowing, + * so the catch block only needs to log — callers do not need to send a second notification. This + * matches {@link handleSaveAsNew}, which uses the same command and follows the same pattern. * * @param config - The configuration collected by the New dialog. - * @returns A promise that resolves once the project is created (or the failure is handled). + * @returns `true` if the project was created and persisted successfully; `false` otherwise. + * Callers are responsible for closing the modal on success and keeping it open on failure so + * the user can retry without re-entering their inputs. */ const createAndPersistProject = useCallback( - async (config: CreateDraftConfig) => { + async (config: CreateDraftConfig): Promise => { newDraft({ analysisLanguages: config.analysisLanguages, ...(config.name !== undefined && { suggestedName: config.name }), @@ -254,19 +256,15 @@ export default function ProjectModals({ } } catch (e) { logger.error('Interlinearizer: failed to create project from New dialog', e); - await papi.notifications - .send({ message: '%interlinearizer_error_create_project_failed%', severity: 'error' }) - .catch(() => {}); } if (created) { setActiveProject(created); } else { resetActiveProject(); } - setCreateSourceIsSelect(false); - setModal('none'); + return created !== undefined; }, - [newDraft, projectId, resetActiveProject, setActiveProject, setModal], + [newDraft, projectId, resetActiveProject, setActiveProject], ); /** @@ -286,7 +284,8 @@ export default function ProjectModals({ /** * Called when the New dialog is submitted. Creates and persists the project immediately, or * defers behind the unsaved-changes confirmation when the draft is dirty. Disables the modal - * buttons via `isCreating` during the backend round-trip. + * buttons via `isCreating` during the backend round-trip. Closes on success; stays open on + * failure so the user can retry without re-entering their inputs. * * @param config - The configuration collected by the New dialog. */ @@ -298,15 +297,24 @@ export default function ProjectModals({ } setIsCreating(true); try { - await createAndPersistProject(config); + const success = await createAndPersistProject(config); + if (success) { + setCreateSourceIsSelect(false); + setModal('none'); + } } finally { setIsCreating(false); } }, - [createAndPersistProject, dirty], + [createAndPersistProject, dirty, setCreateSourceIsSelect, setModal], ); - /** Confirms the deferred draft-replacing action after the user accepts losing unsaved changes. */ + /** + * Confirms the deferred draft-replacing action after the user accepts losing unsaved changes. For + * a deferred Open, delegates entirely to {@link openProject}. For a deferred New, closes on + * success; on failure the discard confirm is dismissed but the underlying create modal stays + * visible so the user can retry. + */ const handleConfirmReplace = useCallback(async () => { /* v8 ignore next -- the confirm only renders while a pending action exists */ if (!pendingReplace) return; @@ -321,7 +329,11 @@ export default function ProjectModals({ } else { setIsCreating(true); try { - await createAndPersistProject(pendingReplace.config); + const success = await createAndPersistProject(pendingReplace.config); + if (success) { + setCreateSourceIsSelect(false); + setModal('none'); + } } finally { setIsCreating(false); } @@ -330,7 +342,14 @@ export default function ProjectModals({ setIsReplacing(false); setPendingReplace(undefined); } - }, [createAndPersistProject, isReplacing, openProject, pendingReplace]); + }, [ + createAndPersistProject, + isReplacing, + openProject, + pendingReplace, + setCreateSourceIsSelect, + setModal, + ]); /** Cancels the deferred action, returning to the underlying modal with the draft untouched. */ const handleCancelReplace = useCallback(() => setPendingReplace(undefined), []); diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index 3e0f9bc0..0bfd289b 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -68,9 +68,9 @@ export type UseDraftProjectResult = { loadFromProject: (project: OpenableProject) => void; /** * Starts a fresh, empty draft for the current source — the "New" flow. Seeds the chosen analysis - * languages and retains the typed name/description as `suggestedName`/`suggestedDescription` to - * prefill Save As; no backend project is created until the user explicitly saves. The new draft - * is clean (`dirty: false`), so the unsaved-changes indicator stays clear until the first edit. + * languages and retains the typed name/description as `suggestedName`/`suggestedDescription`. The + * new draft is clean (`dirty: false`), so the unsaved-changes indicator stays clear until the + * first edit. The caller is responsible for immediately persisting the project to the backend. * * @param config - The languages and optional suggested name/description from the New dialog. */ From 5f32de456f8a4d77bc3c1625c19aa2d61643232b Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 22 Jun 2026 17:30:35 -0400 Subject: [PATCH 5/7] Cover discard-confirm -> create success path in tests The handleConfirmReplace else branch was uncovered: the existing test used the default mock (undefined -> JSON.parse throws -> success false), leaving the if (success) close path unreached. Co-Authored-By: Claude Sonnet 4.6 --- .../components/modals/ProjectModals.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index d3c4c53a..c47efb4d 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -657,6 +657,29 @@ describe('ProjectModals', () => { ); }); + it('creates the project and closes after confirming the discard on a dirty draft', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); + const setModal = jest.fn(); + const setActiveProject = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByTestId('create-submit')); + expect(screen.getByTestId('discard-modal')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('discard-confirm')); + await waitFor(() => expect(setModal).toHaveBeenCalledWith('none')); + expect(setActiveProject).toHaveBeenCalledWith(MOCK_PROJECT); + }); + it('disables the discard-confirm button while an open is in flight', async () => { let resolveGet!: (value: string) => void; jest.mocked(papi.commands.sendCommand).mockReturnValueOnce( From 9e8c76c18bd3965d8c25223d3320c1a76831fa3f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 23 Jun 2026 14:05:05 -0400 Subject: [PATCH 6/7] Fix false-negative in discard-confirm pre-submit assertion expect.anything() does not match undefined, so not.toHaveBeenCalledWith with five expect.anything() placeholders passed even when createProject was called with undefined as arg 4. Replace with the exact expected argument values so the assertion correctly guards the not-yet-called path. Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/components/modals/ProjectModals.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index c47efb4d..e0703669 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -632,11 +632,11 @@ describe('ProjectModals', () => { expect(newDraft).not.toHaveBeenCalled(); expect(papi.commands.sendCommand).not.toHaveBeenCalledWith( 'interlinearizer.createProject', - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), + 'source-proj', + ['en'], + undefined, + 'New', + 'Desc', ); await userEvent.click(screen.getByTestId('discard-confirm')); From d89e86fa6934be853b0a2093aded7a8b28203602 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 23 Jun 2026 14:08:13 -0400 Subject: [PATCH 7/7] Restore expect.anything() for non-undefined args in createProject guard Only arg 4 (undefined targetProjectId) needed to be explicit; the other arguments are non-undefined so expect.anything() is correct. Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/components/modals/ProjectModals.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index e0703669..c29896e0 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -632,11 +632,11 @@ describe('ProjectModals', () => { expect(newDraft).not.toHaveBeenCalled(); expect(papi.commands.sendCommand).not.toHaveBeenCalledWith( 'interlinearizer.createProject', - 'source-proj', - ['en'], + expect.anything(), + expect.anything(), undefined, - 'New', - 'Desc', + expect.anything(), + expect.anything(), ); await userEvent.click(screen.getByTestId('discard-confirm'));