diff --git a/CHANGELOG.md b/CHANGELOG.md index 21c95d94..0224796d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.17.4 + +### Patch Changes + +- a9b8694: Restore OpenTUI's platform-default renderer threading to improve interactive startup on macOS. +- 67674fa: Rapidly pressing Ctrl+S (or double-clicking Save) while saving a draft note no longer saves the same note twice, and saved note ids stay unique even within one millisecond. + ## 0.17.3 ### Patch Changes diff --git a/package.json b/package.json index 8fdb48d6..567a0011 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hunkdiff", - "version": "0.17.3", + "version": "0.17.4", "description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.", "keywords": [ "ai", diff --git a/src/main.tsx b/src/main.tsx index 7d1fcceb..f864f14a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -83,6 +83,7 @@ async function main() { >(createSessionRegistration(bootstrap), createInitialSessionSnapshot(bootstrap)); hostClient.start(); + // Keep OpenTUI's platform-safe threading default (enabled on macOS, disabled on Linux). const renderer = await createCliRenderer({ stdin: controllingTerminal?.stdin, stdout: process.stdout, @@ -90,7 +91,6 @@ async function main() { hasControllingTerminal: Boolean(controllingTerminal), }), screenMode: "alternate-screen", - useThread: false, exitOnCtrlC: false, openConsoleOnError: true, onDestroy: () => controllingTerminal?.close(), diff --git a/src/ui/hooks/useReviewController.test.tsx b/src/ui/hooks/useReviewController.test.tsx index 8dd65666..afd2ac1e 100644 --- a/src/ui/hooks/useReviewController.test.tsx +++ b/src/ui/hooks/useReviewController.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, StrictMode, useEffect, useState } from "react"; import { SourceTextTooLargeError } from "../../core/fileSource"; @@ -543,6 +543,60 @@ describe("useReviewController", () => { } }); + test("rapid duplicate saves persist exactly one user note with a unique id", async () => { + const { controllerRef, setup } = await renderReviewController([createAlphaFile()]); + const fixedNow = 1_700_000_000_000; + const dateNowSpy = spyOn(Date, "now").mockReturnValue(fixedNow); + + try { + await flush(setup); + + await act(async () => { + expectValue(controllerRef.current).startUserNote(); + expectValue(controllerRef.current).updateDraftNote("Save me once."); + }); + await flush(setup); + + // Coalesced Ctrl+S key events invoke save twice before the draft-clearing + // state update commits; only the first call may persist a note. + const savedIds: { first?: string; second?: string; followUp?: string } = {}; + await act(async () => { + const controller = expectValue(controllerRef.current); + savedIds.first = controller.saveDraftNote()?.id; + savedIds.second = controller.saveDraftNote()?.id; + }); + await flush(setup); + + expect(savedIds.first).toBe(`user:${fixedNow}-1`); + expect(savedIds.second).toBeUndefined(); + expect(expectValue(controllerRef.current).userNotesByFileId.alpha).toHaveLength(1); + + // A follow-up draft saved within the same millisecond still gets a unique id. + await act(async () => { + expectValue(controllerRef.current).startUserNote(); + }); + await flush(setup); + await act(async () => { + expectValue(controllerRef.current).updateDraftNote("Save me too."); + }); + await flush(setup); + + await act(async () => { + savedIds.followUp = expectValue(controllerRef.current).saveDraftNote()?.id; + }); + await flush(setup); + + expect(savedIds.followUp).toBe(`user:${fixedNow}-2`); + expect(savedIds.followUp).not.toBe(savedIds.first); + expect(expectValue(controllerRef.current).userNotesByFileId.alpha).toHaveLength(2); + } finally { + dateNowSpy.mockRestore(); + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("session clear can include human user notes", async () => { const { controllerRef, setup } = await renderReviewController([createTwoHunkFile()]); diff --git a/src/ui/hooks/useReviewController.ts b/src/ui/hooks/useReviewController.ts index 6e14f203..dae08a5a 100644 --- a/src/ui/hooks/useReviewController.ts +++ b/src/ui/hooks/useReviewController.ts @@ -196,6 +196,11 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon ); const [userNotesByFileId, setUserNotesByFileId] = useState>({}); const [draftNote, setDraftNote] = useState(null); + // Track the last saved draft id so coalesced save key events dedup synchronously + // without waiting for the draft-clearing state update to commit. + const savedDraftIdRef = useRef(null); + // Monotonic suffix that keeps `user:*` note ids unique within one millisecond. + const userNoteSequenceRef = useRef(0); const [expandedGapsByFileId, setExpandedGapsByFileId] = useState< Record> >({}); @@ -821,6 +826,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon newRange: target.side === "new" ? [target.line, target.line] : undefined, body: "", }; + savedDraftIdRef.current = null; setDraftNote(draft); selectHunk( file.id, @@ -842,9 +848,9 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon setDraftNote(null); }, []); - /** Persist the active draft into the in-memory user note collection. */ + /** Persist the active draft into the in-memory user note collection exactly once. */ const saveDraftNote = useCallback((): UserReviewNote | null => { - if (!draftNote) { + if (!draftNote || savedDraftIdRef.current === draftNote.id) { return null; } @@ -854,8 +860,10 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon return null; } + savedDraftIdRef.current = draftNote.id; + const savedNote: UserReviewNote = { - id: `user:${Date.now()}`, + id: `user:${Date.now()}-${++userNoteSequenceRef.current}`, source: "user", filePath: draftNote.filePath, hunkIndex: draftNote.hunkIndex, diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts index f30957b1..5838e529 100644 --- a/test/pty/notes.test.ts +++ b/test/pty/notes.test.ts @@ -116,6 +116,40 @@ describe("PTY notes", () => { } }); + test("rapid Ctrl+S presses save a draft note exactly once", async () => { + const fixture = harness.createLongWrapFilePair(); + const session = await harness.launchHunk({ + args: ["diff", fixture.before, fixture.after, "--mode", "split"], + cols: 120, + rows: 24, + }); + + try { + await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); + + await session.press("c"); + await session.waitForText(/Draft note/, { timeout: 5_000 }); + await session.type("Save exactly one note."); + await session.waitForText(/Save exactly one note\./, { timeout: 5_000 }); + + // Send both Ctrl+S bytes in one PTY write so the second save request runs + // before the draft-clearing state update commits. + session.writeRaw("\x13\x13"); + await session.waitIdle(); + + const saved = await session.waitForText(/Your note/, { timeout: 5_000 }); + expect(saved).toContain("Save exactly one note."); + + // A duplicated save renders numbered "Your note 1/2" cards; a single save must not. + await sleep(250); + const settled = await session.text({ immediate: true }); + expect(settled).not.toContain("Your note 1/"); + expect((settled.match(/Your note/g) ?? []).length).toBe(1); + } finally { + session.close(); + } + }); + test("add-note affordance appears only after mouse movement in a real PTY", async () => { const fixture = harness.createScrollableFilePair(); const session = await harness.launchHunk({