From 0210faabeaa31ad8c8d8ac902937bb78bc57dd78 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 3 Jul 2026 18:19:52 +0300 Subject: [PATCH] fix(editor): read editor.isEditable reactively in DictationGroup so the mic un-greys after sync (#311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an editable page the dictation mic in the byline stayed grey/disabled after collab finished syncing, until an unrelated re-render (view↔edit toggle, navigation) happened. DictationGroup read the NON-reactive `editor.isEditable` field directly in render; `editor` comes from pageEditorAtom (a stable object reference), so `editor.setEditable(true)` after sync (#218 gate) mutates TipTap state without changing the atom reference — the byline never re-renders and disabled=true sticks. Read editable via `useEditorState` (the same reactive read the editor body already uses), so the mic re-enables when the body flips editable and disables again when it loses editable. The #218 pre-sync intent is preserved — just made reactive. Test flips isEditable false→true and asserts the mic goes disabled→enabled (fails against the pre-fix raw-field read). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../groups/dictation-group.test.tsx | 75 +++++++++++++++++++ .../fixed-toolbar/groups/dictation-group.tsx | 12 ++- 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/features/editor/components/fixed-toolbar/groups/dictation-group.test.tsx diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/dictation-group.test.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/dictation-group.test.tsx new file mode 100644 index 00000000..6aaf6983 --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/dictation-group.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from "vitest"; +import { useEffect, useState } from "react"; +import { render, act } from "@testing-library/react"; + +// Regression test for #311: on a page the user can edit, the byline mic stayed +// stuck disabled until an unrelated re-render happened, because DictationGroup +// read the non-reactive field `editor.isEditable` directly. The fix reads it via +// `useEditorState`, which subscribes to the editor's own events. +// +// The mock below mirrors the real `useEditorState` contract: it runs the +// selector, and re-runs it (re-rendering the consumer) whenever the editor emits +// an event. This is what makes the test faithful — with the pre-fix code +// (`disabled={!editor.isEditable}`) DictationGroup never subscribes, so emitting +// an event would NOT re-render and the mic would stay disabled. +vi.mock("@tiptap/react", () => ({ + useEditorState: ({ editor, selector }: any) => { + const [value, setValue] = useState(() => selector({ editor })); + useEffect(() => { + const handler = () => setValue(selector({ editor })); + editor.on("update", handler); + return () => editor.off("update", handler); + }, [editor, selector]); + return value; + }, +})); + +// The mic only cares about the workspace's streaming flag; return a stable stub. +vi.mock("jotai", () => ({ + useAtomValue: () => ({ settings: { ai: { dictationStreaming: false } } }), +})); +vi.mock("@/features/user/atoms/current-user-atom.ts", () => ({ + workspaceAtom: {}, +})); + +// Detectable stand-in that surfaces the `disabled` prop the component computes. +vi.mock("@/features/dictation/components/mic-button", () => ({ + MicButton: ({ disabled }: any) => ( +