From c9668cbe60de4961f8df52302f2c12d33db4b5b3 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 3 Jul 2026 19:25:02 +0300 Subject: [PATCH] fix(dictation): extract tested computeDictationAvailability + gate mic from the reactive atom (#314 F1/F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1: the availability publish-effect duplicated the #218 editability gate (editable && inEditMode && !showStatic) inline — a copy that could silently diverge from the tested isBodyEditable — and the reason computation (the core of #309) had no tests. Extract computeDictationAvailability into editor-sync-state.ts REUSING isBodyEditable; the effect is now a one-line call. Unit tests cover the branches (synced→null; pre-sync disconnected→offline / else connecting; !editable/!edit→read-only). F2: DictationGroup gated the mic on the non-reactive editor.isEditable while the PR already publishes the reactive dictationAvailability.isEditable (same signals) — so gate and reason came from different sources and the mic could stick. Gate on dictationAvailability.isEditable: one reactive source of truth for both. vitest (editor-sync-state + dictation): 37 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fixed-toolbar/groups/dictation-group.tsx | 2 +- .../features/editor/editor-sync-state.test.ts | 54 ++++++++++++++++++- .../src/features/editor/editor-sync-state.ts | 30 +++++++++++ .../src/features/editor/page-editor.tsx | 26 ++++----- 4 files changed, 93 insertions(+), 19 deletions(-) diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/dictation-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/dictation-group.tsx index e33b3f15..135e9b20 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/groups/dictation-group.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/dictation-group.tsx @@ -83,7 +83,7 @@ export const DictationGroup: FC = ({ editor, color, iconSize }) => { streaming={streamingDictation} onStart={handleStart} onText={handleText} - disabled={!editor.isEditable} + disabled={!dictationAvailability.isEditable} unavailableReason={dictationAvailability.reason ?? undefined} color={color} iconSize={iconSize} diff --git a/apps/client/src/features/editor/editor-sync-state.test.ts b/apps/client/src/features/editor/editor-sync-state.test.ts index 7d31c292..e21a8585 100644 --- a/apps/client/src/features/editor/editor-sync-state.test.ts +++ b/apps/client/src/features/editor/editor-sync-state.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from "vitest"; import { WebSocketStatus } from "@hocuspocus/provider"; -import { isCollabSynced, isBodyEditable } from "./editor-sync-state"; +import { + isCollabSynced, + isBodyEditable, + computeDictationAvailability, +} from "./editor-sync-state"; describe("isCollabSynced", () => { it("is true only when Connected and synced", () => { @@ -30,3 +34,51 @@ describe("isBodyEditable (pre-sync data-loss gate, #218)", () => { expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false); }); }); + +describe("computeDictationAvailability (mic reason precedence, #309)", () => { + const base = { + editable: true, + inEditMode: true, + showStatic: false, + isDisconnected: false, + }; + + it("is available with no reason once synced (showStatic false)", () => { + expect(computeDictationAvailability(base)).toEqual({ + isEditable: true, + reason: null, + }); + }); + + it("reports 'offline' during pre-sync while disconnected", () => { + expect( + computeDictationAvailability({ + ...base, + showStatic: true, + isDisconnected: true, + }), + ).toEqual({ isEditable: false, reason: "offline" }); + }); + + it("reports 'connecting' during pre-sync while still connecting", () => { + expect( + computeDictationAvailability({ + ...base, + showStatic: true, + isDisconnected: false, + }), + ).toEqual({ isEditable: false, reason: "connecting" }); + }); + + it("reports 'read-only' without edit permission", () => { + expect( + computeDictationAvailability({ ...base, editable: false }), + ).toEqual({ isEditable: false, reason: "read-only" }); + }); + + it("reports 'read-only' when not in edit mode", () => { + expect( + computeDictationAvailability({ ...base, inEditMode: false }), + ).toEqual({ isEditable: false, reason: "read-only" }); + }); +}); diff --git a/apps/client/src/features/editor/editor-sync-state.ts b/apps/client/src/features/editor/editor-sync-state.ts index 6bb657cc..48066269 100644 --- a/apps/client/src/features/editor/editor-sync-state.ts +++ b/apps/client/src/features/editor/editor-sync-state.ts @@ -1,4 +1,5 @@ import { WebSocketStatus } from "@hocuspocus/provider"; +import type { DictationUnavailableReason } from "@/features/dictation/dictation-status"; /** * The collab document is usable only once the provider is Connected AND has @@ -30,3 +31,32 @@ export function isBodyEditable(opts: { }): boolean { return opts.editable && opts.inEditMode && !opts.showStatic; } + +/** + * Whether dictation can start and, when it can't, the cause-specific reason the + * mic button surfaces. Derives editability from `isBodyEditable` (the single, + * tested gate) so the published `isEditable` can never diverge from the actual + * body-editable state and make the tooltip lie (#309). + * + * `isDisconnected` is the caller's own boolean (collab connection is in the + * Disconnected state), passed in so this module stays free of the collab enum. + */ +export function computeDictationAvailability(opts: { + editable: boolean; + inEditMode: boolean; + showStatic: boolean; + isDisconnected: boolean; +}): { isEditable: boolean; reason: DictationUnavailableReason | null } { + const isEditable = isBodyEditable({ + editable: opts.editable, + inEditMode: opts.inEditMode, + showStatic: opts.showStatic, + }); + if (isEditable) return { isEditable, reason: null }; + // Permitted to edit and in edit mode but not yet synced (showStatic) → pre-sync. + if (opts.editable && opts.inEditMode && opts.showStatic) { + return { isEditable, reason: opts.isDisconnected ? "offline" : "connecting" }; + } + // No edit permission or not in edit mode. + return { isEditable, reason: "read-only" }; +} diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index f746a568..c5b0abde 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -36,7 +36,6 @@ import { pageEditorAtom, yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms"; -import type { DictationUnavailableReason } from "@/features/dictation/dictation-status"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; import { activeCommentIdAtom, @@ -89,6 +88,7 @@ import { PageEmbedAncestryProvider } from "@/features/editor/components/page-emb import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker"; import { useTranslation } from "react-i18next"; import { + computeDictationAvailability, isBodyEditable, isCollabSynced, } from "@/features/editor/editor-sync-state"; @@ -478,22 +478,14 @@ export default function PageEditor({ // the mic button surfaces. Recomputed on the same signals that drive body // editability so the tooltip never lies about the current state. useEffect(() => { - const inEditMode = currentPageEditMode === PageEditMode.Edit; - const isEditable = editable && inEditMode && !showStatic; // mirrors editor.isEditable - let reason: DictationUnavailableReason | null = null; - if (!isEditable) { - if (editable && inEditMode && showStatic) { - // Permitted to edit and in edit mode, but the collab doc hasn't synced yet. - reason = - yjsConnectionStatus === WebSocketStatus.Disconnected - ? "offline" - : "connecting"; - } else { - // No edit permission or not in edit mode. - reason = "read-only"; - } - } - setDictationAvailability({ isEditable, reason }); + setDictationAvailability( + computeDictationAvailability({ + editable, + inEditMode: currentPageEditMode === PageEditMode.Edit, + showStatic, + isDisconnected: yjsConnectionStatus === WebSocketStatus.Disconnected, + }), + ); }, [ editable, currentPageEditMode,