diff --git a/apps/web/src/bench-context-value.ts b/apps/web/src/bench-context-value.ts new file mode 100644 index 000000000..10f16b43e --- /dev/null +++ b/apps/web/src/bench-context-value.ts @@ -0,0 +1,25 @@ +// The context object itself, apart from the provider that fills it. +// `createContext` mints a fresh object every time its module runs, and a +// module that also exports a component is a React Refresh boundary that a +// hot update re-executes without re-executing its importers — the provider +// would then publish one context while `useBench` still reads the previous +// one. Holding it in a component-free module keeps a single identity for +// every reader. + +import type { APIQuery } from "@/lib/api-query"; +import { createContext } from "react"; + +import type { PrincipalsPage } from "./api"; + +export type BenchState = { + readonly memberships: APIQuery; + readonly selectedTenantId: string | null; + readonly selectedPrincipalId: string | null; + readonly selectTenant: (tenantId: string) => void; + readonly onBenchCreated: (tenantId: string) => void; +}; + +/** Exported only so a render test can inject a fixed `BenchState` without + * standing up `BenchProvider`'s own `/api/me/principals` fetch — every + * real caller still goes through `useBench`/`BenchProvider`. */ +export const BenchContext = createContext(null); diff --git a/apps/web/src/bench-context.tsx b/apps/web/src/bench-context.tsx index b0be365a3..aaff5c17f 100644 --- a/apps/web/src/bench-context.tsx +++ b/apps/web/src/bench-context.tsx @@ -6,15 +6,18 @@ import { isRawIdentifier } from "@/bench"; import { useQueryClient } from "@tanstack/react-query"; -import { createContext, useContext, useMemo, useState } from "react"; +import { useContext, useMemo, useState } from "react"; import type { ReactNode } from "react"; -import type { APIQuery } from "@/lib/api-query"; - import { PrincipalsSchema, useAPIQuery } from "./api"; -import type { Principal, PrincipalsPage } from "./api"; +import type { Principal } from "./api"; +import { BenchContext } from "./bench-context-value"; +import type { BenchState } from "./bench-context-value"; import { meKeys, tenantKeys } from "./query-client"; +export { BenchContext }; +export type { BenchState }; + const STORAGE_KEY = "workbench.selectedTenantId"; function readStoredTenantId(): string | null { @@ -34,19 +37,6 @@ function writeStoredTenantId(tenantId: string): void { } } -export type BenchState = { - readonly memberships: APIQuery; - readonly selectedTenantId: string | null; - readonly selectedPrincipalId: string | null; - readonly selectTenant: (tenantId: string) => void; - readonly onBenchCreated: (tenantId: string) => void; -}; - -/** Exported only so a render test can inject a fixed `BenchState` without - * standing up `BenchProvider`'s own `/api/me/principals` fetch — every - * real caller still goes through `useBench`/`BenchProvider`. */ -export const BenchContext = createContext(null); - /** The membership this context currently treats as selected: the stored * choice if it still names a bench the account belongs to, otherwise the * first named membership — the same personal-bench convention diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index d02525e6d..3a10ee147 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,115 +1,28 @@ +// The mount, and nothing else. This module declares no component on +// purpose: a module that declares one becomes a React Refresh boundary, +// and a hot update then re-executes it in place — calling `createRoot` on +// `#root` a second time and leaving two reconcilers committing into one +// container. The root itself is kept on the HMR data slot so even a +// re-execution reuses the single root it already created. + import "@corbits/react-ui/styles.css"; import "./app.css"; import "./tailwind.css"; -import { ThemeProvider, Toaster, toast } from "@corbits/react-ui"; -import { StrictMode, useCallback, useEffect, useState, useSyncExternalStore } from "react"; -import { createRoot } from "react-dom/client"; +import { StrictMode } from "react"; +import { createRoot, type Root as ReactRoot } from "react-dom/client"; -import { getLogger } from "@/lib/client-log"; import { AppErrorBoundary } from "./app-error-boundary"; -import { App } from "./app"; -import { validatedNextPath } from "./login-next"; -import { triggerFirstLoginProvisioning } from "./onboarding"; -import { getPath, navigateTo, subscribeToPath } from "./router-store"; -import { ONBOARDING_PATH } from "./routes"; -import { fetchSession, signOut } from "./session"; -import type { SessionState, SessionUser } from "./session"; - -const log = getLogger("web.session"); - -function Root() { - // History lives outside React (`./router-store`), so the path is a - // subscription, not an effect that starts listening after first paint. - const path = useSyncExternalStore(subscribeToPath, getPath); - const navigate = navigateTo; - - const [session, setSession] = useState({ kind: "loading" }); - const probe = useCallback(() => { - setSession({ kind: "loading" }); - void fetchSession().then(setSession); - }, []); - useEffect(probe, [probe]); - - const handleSignedIn = useCallback( - (user: SessionUser) => { - setSession({ kind: "signed-in", user }); - navigate(validatedNextPath(window.location.search)); - }, - [navigate], - ); - - // The first-login hook: once per session that reaches signed-in, ask - // the hub's native setup-status route whether any bench exists yet. An - // empty hub reports setup-required so we route into the setup screen; - // a hub with tenants loads the shell normally. Read-only on purpose - // — this never mints anything. A failure blocks the shell - // entirely rather than leaving the user silently benchless. - const [provisioningError, setProvisioningError] = useState<{ - message: string; - refId?: string | undefined; - } | null>(null); - const provisionedUserId = session.kind === "signed-in" ? session.user.id : null; - const runProvisioning = useCallback(() => { - if (provisionedUserId === null) return () => undefined; - let cancelled = false; - setProvisioningError(null); - void triggerFirstLoginProvisioning().then((result) => { - if (cancelled) return; - if (result.kind === "needs-onboarding") { - navigate(ONBOARDING_PATH); - } else if (result.kind === "error") { - setProvisioningError({ message: result.message, refId: result.refId }); - } - }); - return () => { - cancelled = true; - }; - }, [provisionedUserId, navigate]); - useEffect(runProvisioning, [runProvisioning]); - const handleRetryProvisioning = useCallback(() => { - runProvisioning(); - }, [runProvisioning]); - - const handleSignOut = useCallback(() => { - setSession({ kind: "signed-out" }); - toast("Signed out. If you were on a shared computer, close the browser to be sure."); - void signOut().then((ok) => { - if (ok) return; - log.error("Sign-out request to the server failed"); - }); - }, []); - - // Per-user storage when signed in so theme preference follows the account; - // signed-out / loading share the anonymous host key. Not synced to the - // preferences store: @corbits/react-ui's ThemeProvider owns mode - // entirely internally (localStorage read/write on setMode/cycleMode) and - // exposes no onChange hook or externally-supplied initial value a host - // could observe or override without forking the component. - const themeStorageKey = - session.kind === "signed-in" ? `corbits-theme:${session.user.id}` : "corbits-theme"; - - return ( - - - - - ); -} +import { Root } from "./root"; const container = document.getElementById("root"); if (container === null) throw new Error("index.html is missing #root"); -createRoot(container).render( + +const hotData = import.meta.hot?.data as { root?: ReactRoot } | undefined; +const root = hotData?.root ?? createRoot(container); +if (hotData !== undefined) hotData.root = root; + +root.render( diff --git a/apps/web/src/navigation-context.ts b/apps/web/src/navigation-context.ts new file mode 100644 index 000000000..7849d6ef3 --- /dev/null +++ b/apps/web/src/navigation-context.ts @@ -0,0 +1,25 @@ +// The navigation context objects, apart from the provider and hooks that +// use them — see `bench-context-value.ts` for why a `createContext` call +// never shares a module with a component. + +import { createContext } from "react"; + +import type { SessionUser } from "./session"; + +export type Navigate = (to: string) => void; + +export const NavigateContext = createContext(() => { + throw new Error("navigation used outside NavigationProvider"); +}); + +/** Absent outside a signed-in shell (the onboarding wizard has no account + * menu, no settings surface) — `undefined` rather than a throwing default, + * so a reader like `AccountSection` (mounted in package tests with no + * provider at all) can simply omit the Sign out action instead of + * crashing. */ +export const SignOutContext = createContext<(() => void) | undefined>(undefined); + +/** Same availability rule as `SignOutContext`: present in the signed-in + * shell so surfaces like `ChatPage` can label the reader's own avatar from + * the auth account, undefined outside that shell. */ +export const SessionUserContext = createContext(undefined); diff --git a/apps/web/src/navigation.tsx b/apps/web/src/navigation.tsx index c94f43bf6..bebdafff0 100644 --- a/apps/web/src/navigation.tsx +++ b/apps/web/src/navigation.tsx @@ -4,28 +4,14 @@ // browser — the hub serves index.html for every non-/api path, so a full page // load lands on the same route. -import { createContext, useContext } from "react"; +import { useContext } from "react"; import type { ComponentProps, MouseEvent, ReactNode } from "react"; +import { NavigateContext, SessionUserContext, SignOutContext } from "./navigation-context"; +import type { Navigate } from "./navigation-context"; import type { SessionUser } from "./session"; -export type Navigate = (to: string) => void; - -const NavigateContext = createContext(() => { - throw new Error("navigation used outside NavigationProvider"); -}); - -/** Absent outside a signed-in shell (the onboarding wizard has no account - * menu, no settings surface) — `undefined` rather than a throwing default, - * so a reader like `AccountSection` (mounted in package tests with no - * provider at all) can simply omit the Sign out action instead of - * crashing. */ -const SignOutContext = createContext<(() => void) | undefined>(undefined); - -/** Same availability rule as `SignOutContext`: present in the signed-in - * shell so surfaces like `ChatPage` can label the reader's own avatar from - * the auth account, undefined outside that shell. */ -const SessionUserContext = createContext(undefined); +export type { Navigate }; export function NavigationProvider({ navigate, diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx new file mode 100644 index 000000000..86da1e014 --- /dev/null +++ b/apps/web/src/root.tsx @@ -0,0 +1,106 @@ +// The top of the React tree: browser history, the one session probe, the +// first-login hook, and the theme shell everything else renders inside. +// Kept out of `main.tsx` so the entry module owns nothing but the mount. + +import { ThemeProvider, Toaster, toast } from "@corbits/react-ui"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; + +import { getLogger } from "@/lib/client-log"; +import { App } from "./app"; +import { validatedNextPath } from "./login-next"; +import { triggerFirstLoginProvisioning } from "./onboarding"; +import { getPath, navigateTo, subscribeToPath } from "./router-store"; +import { ONBOARDING_PATH } from "./routes"; +import { fetchSession, signOut } from "./session"; +import type { SessionState, SessionUser } from "./session"; + +const log = getLogger("web.session"); + +export function Root() { + // History lives outside React (`./router-store`), so the path is a + // subscription, not an effect that starts listening after first paint. + const path = useSyncExternalStore(subscribeToPath, getPath); + const navigate = navigateTo; + + const [session, setSession] = useState({ kind: "loading" }); + const probe = useCallback(() => { + setSession({ kind: "loading" }); + void fetchSession().then(setSession); + }, []); + useEffect(probe, [probe]); + + const handleSignedIn = useCallback( + (user: SessionUser) => { + setSession({ kind: "signed-in", user }); + navigate(validatedNextPath(window.location.search)); + }, + [navigate], + ); + + // The first-login hook: once per session that reaches signed-in, ask + // the hub's native setup-status route whether any bench exists yet. An + // empty hub reports setup-required so we route into the setup screen; + // a hub with tenants loads the shell normally. Read-only on purpose + // — this never mints anything. A failure blocks the shell + // entirely rather than leaving the user silently benchless. + const [provisioningError, setProvisioningError] = useState<{ + message: string; + refId?: string | undefined; + } | null>(null); + const provisionedUserId = session.kind === "signed-in" ? session.user.id : null; + const runProvisioning = useCallback(() => { + if (provisionedUserId === null) return () => undefined; + let cancelled = false; + setProvisioningError(null); + void triggerFirstLoginProvisioning().then((result) => { + if (cancelled) return; + if (result.kind === "needs-onboarding") { + navigate(ONBOARDING_PATH); + } else if (result.kind === "error") { + setProvisioningError({ message: result.message, refId: result.refId }); + } + }); + return () => { + cancelled = true; + }; + }, [provisionedUserId, navigate]); + useEffect(runProvisioning, [runProvisioning]); + const handleRetryProvisioning = useCallback(() => { + runProvisioning(); + }, [runProvisioning]); + + const handleSignOut = useCallback(() => { + setSession({ kind: "signed-out" }); + toast("Signed out. If you were on a shared computer, close the browser to be sure."); + void signOut().then((ok) => { + if (ok) return; + log.error("Sign-out request to the server failed"); + }); + }, []); + + // Per-user storage when signed in so theme preference follows the account; + // signed-out / loading share the anonymous host key. Not synced to the + // preferences store: @corbits/react-ui's ThemeProvider owns mode + // entirely internally (localStorage read/write on setMode/cycleMode) and + // exposes no onChange hook or externally-supplied initial value a host + // could observe or override without forking the component. + const themeStorageKey = + session.kind === "signed-in" ? `corbits-theme:${session.user.id}` : "corbits-theme"; + + return ( + + + + + ); +} diff --git a/apps/web/src/shell/canvas-availability.tsx b/apps/web/src/shell/canvas-availability.tsx index 98878c0f6..a6150254e 100644 --- a/apps/web/src/shell/canvas-availability.tsx +++ b/apps/web/src/shell/canvas-availability.tsx @@ -4,116 +4,19 @@ // AppShell's own render, which no longer owns this state — what the canvas // column is actually showing right now. -import { createContext, useContext, type ReactNode } from "react"; +import { useContext, type ReactNode } from "react"; import type { ProfileSubject } from "@/chat"; -import type { ArtifactRendererKind } from "@/library"; -import type { CanvasColumnState } from "@/shell/layout"; -/** The canvas's typed-artifact pane: a title, the already-resolved - * renderer selection (see `@/library`'s `resolveArtifactRendererKind` - * / `resolveRendererKindFromMediaType`), and the content string those - * renderers read. - * - * `canEdit` (phase 2) opts a text-kind ("doc") artifact into - * `ArtifactTextEditor` instead of the read-only `ArtifactRenderer` — - * defaults to `false`/absent so every existing caller keeps rendering - * read-only with zero behavior change. The presence `/update` route's own - * `asset:*`/"write" grant check is the real security boundary regardless - * of this flag; `canEdit` only decides which component a capable viewer - * sees, never whether a write actually lands. */ -export type CanvasArtifactContent = { - readonly id: string; - readonly title: string; - readonly rendererKind: ArtifactRendererKind; - readonly content: string; - readonly unavailableReason?: string; - readonly canEdit?: boolean; - /** The sandboxed preview route for an `"html"`-kind artifact — - * see `ArtifactRenderProps.previewSrc`. Absent for every other renderer - * kind, and for an HTML blob with no Library artifact id to preview. */ - readonly previewSrc?: string; -}; - -/** The canvas's routine pane subject: which routine to show, or `null` to - * start a brand-new one. Distinct from `CanvasArtifactContent` — the panel - * fetches and owns its own routine data (name, instruction, trigger, run - * history) from `routineId`, the same way `ProfileCanvasPane` fetches - * shared workbenches from a `ProfileSubject`'s address rather than being - * handed pre-resolved content. */ -export type RoutinePanelSubject = { - /** Always opens the editor: a specific routine (`routineId` set) or a - * brand-new one (`routineId` omitted or `null`) — routines-page's own - * "New routine"/"Edit" actions, "Make this a routine", the composer's - * `/routine` command, and "New routine in this space" (: - * browsing/running existing routines moved to the global `/workflows` - * page, so this pane no longer has a list mode). */ - readonly routineId?: string | null; - /** Seeds the Name/Instruction fields the instant a brand-new panel opens - * (`routineId: null` only) — "Make this a routine" (a completed task - * result) and similar callers with something worth pre-filling. The - * panel still autosaves on the person's own edits; this only seeds the - * initial draft. */ - readonly initialName?: string; - readonly initialInstruction?: string; - /** The conversation this routine belongs to — its own agent (the - * workbench's host participant; every workbench's host is Myra) backs the - * routine, and its own id is where the routine delivers. Carried through - * list mode too, so "New routine" picked from the list still binds to - * the workbench the panel was opened beside. Omitted only when there is no - * open conversation to bind to (e.g. a deliberate `/workflows` visit), - * in which case the panel falls back to this workbench's own default - * (Myra) workbench — never mints a new one. */ - readonly workbenchId?: string; - /** Seeds the target picker's initial selection — the - * conversation's own single agent participant's definition asset id, - * when the opener could resolve exactly one. Shown visibly in - * `DefinitionTargetPicker` and freely replaceable/clearable by the - * person; only their final explicit pick is ever sent to the backend. - * Omitted whenever the opener found zero or several candidates, or has - * no conversation to derive one from at all. */ - readonly preselectedAssetId?: string; -}; - -/** Workbench's concrete instantiation of `@/shell/layout`'s generic - * canvas state — a `ProfileSubject` for the profile pane, this app's own - * `CanvasArtifactContent` for the artifact pane, `RoutinePanelSubject` for - * the routine pane. */ -export type AppCanvasColumnState = CanvasColumnState< - ProfileSubject, +import { CanvasHostContext } from "./canvas-host-context"; +import type { + AppCanvasColumnState, CanvasArtifactContent, - RoutinePanelSubject ->; - -export type CanvasHost = { - readonly allowed: boolean; - readonly open: boolean; - readonly profile: ProfileSubject | null; - readonly artifact: CanvasArtifactContent | null; - readonly routine: RoutinePanelSubject | null; - readonly focus: boolean; - readonly openProfile: (subject: ProfileSubject) => void; - readonly openArtifact: (artifact: CanvasArtifactContent) => void; - readonly openRoutine: (subject: RoutinePanelSubject) => void; - readonly toggleFocus: () => void; - /** Closes whichever content the canvas currently shows (profile, - * artifact, or routine) and drops focus — one seam regardless of what's - * open. */ - readonly close: () => void; -}; + CanvasHost, + RoutinePanelSubject, +} from "./canvas-host-context"; -const CanvasHostContext = createContext({ - allowed: false, - open: false, - profile: null, - artifact: null, - routine: null, - focus: false, - openProfile: () => undefined, - openArtifact: () => undefined, - openRoutine: () => undefined, - toggleFocus: () => undefined, - close: () => undefined, -}); +export { CanvasHostContext }; +export type { AppCanvasColumnState, CanvasArtifactContent, CanvasHost, RoutinePanelSubject }; export function CanvasAvailabilityProvider({ allowed, diff --git a/apps/web/src/shell/canvas-host-context.ts b/apps/web/src/shell/canvas-host-context.ts new file mode 100644 index 000000000..2985d1bee --- /dev/null +++ b/apps/web/src/shell/canvas-host-context.ts @@ -0,0 +1,115 @@ +// The canvas host context object and the types that shape it, apart from +// the provider and hooks around it — see `../bench-context-value.ts` for +// why a `createContext` call never shares a module with a component. + +import { createContext } from "react"; + +import type { ProfileSubject } from "@/chat"; +import type { ArtifactRendererKind } from "@/library"; +import type { CanvasColumnState } from "@/shell/layout"; + +/** The canvas's typed-artifact pane: a title, the already-resolved + * renderer selection (see `@/library`'s `resolveArtifactRendererKind` + * / `resolveRendererKindFromMediaType`), and the content string those + * renderers read. + * + * `canEdit` (phase 2) opts a text-kind ("doc") artifact into + * `ArtifactTextEditor` instead of the read-only `ArtifactRenderer` — + * defaults to `false`/absent so every existing caller keeps rendering + * read-only with zero behavior change. The presence `/update` route's own + * `asset:*`/"write" grant check is the real security boundary regardless + * of this flag; `canEdit` only decides which component a capable viewer + * sees, never whether a write actually lands. */ +export type CanvasArtifactContent = { + readonly id: string; + readonly title: string; + readonly rendererKind: ArtifactRendererKind; + readonly content: string; + readonly unavailableReason?: string; + readonly canEdit?: boolean; + /** The sandboxed preview route for an `"html"`-kind artifact — + * see `ArtifactRenderProps.previewSrc`. Absent for every other renderer + * kind, and for an HTML blob with no Library artifact id to preview. */ + readonly previewSrc?: string; +}; + +/** The canvas's routine pane subject: which routine to show, or `null` to + * start a brand-new one. Distinct from `CanvasArtifactContent` — the panel + * fetches and owns its own routine data (name, instruction, trigger, run + * history) from `routineId`, the same way `ProfileCanvasPane` fetches + * shared workbenches from a `ProfileSubject`'s address rather than being + * handed pre-resolved content. */ +export type RoutinePanelSubject = { + /** Always opens the editor: a specific routine (`routineId` set) or a + * brand-new one (`routineId` omitted or `null`) — routines-page's own + * "New routine"/"Edit" actions, "Make this a routine", the composer's + * `/routine` command, and "New routine in this space" (: + * browsing/running existing routines moved to the global `/workflows` + * page, so this pane no longer has a list mode). */ + readonly routineId?: string | null; + /** Seeds the Name/Instruction fields the instant a brand-new panel opens + * (`routineId: null` only) — "Make this a routine" (a completed task + * result) and similar callers with something worth pre-filling. The + * panel still autosaves on the person's own edits; this only seeds the + * initial draft. */ + readonly initialName?: string; + readonly initialInstruction?: string; + /** The conversation this routine belongs to — its own agent (the + * workbench's host participant; every workbench's host is Myra) backs the + * routine, and its own id is where the routine delivers. Carried through + * list mode too, so "New routine" picked from the list still binds to + * the workbench the panel was opened beside. Omitted only when there is no + * open conversation to bind to (e.g. a deliberate `/workflows` visit), + * in which case the panel falls back to this workbench's own default + * (Myra) workbench — never mints a new one. */ + readonly workbenchId?: string; + /** Seeds the target picker's initial selection — the + * conversation's own single agent participant's definition asset id, + * when the opener could resolve exactly one. Shown visibly in + * `DefinitionTargetPicker` and freely replaceable/clearable by the + * person; only their final explicit pick is ever sent to the backend. + * Omitted whenever the opener found zero or several candidates, or has + * no conversation to derive one from at all. */ + readonly preselectedAssetId?: string; +}; + +/** Workbench's concrete instantiation of `@/shell/layout`'s generic + * canvas state — a `ProfileSubject` for the profile pane, this app's own + * `CanvasArtifactContent` for the artifact pane, `RoutinePanelSubject` for + * the routine pane. */ +export type AppCanvasColumnState = CanvasColumnState< + ProfileSubject, + CanvasArtifactContent, + RoutinePanelSubject +>; + +export type CanvasHost = { + readonly allowed: boolean; + readonly open: boolean; + readonly profile: ProfileSubject | null; + readonly artifact: CanvasArtifactContent | null; + readonly routine: RoutinePanelSubject | null; + readonly focus: boolean; + readonly openProfile: (subject: ProfileSubject) => void; + readonly openArtifact: (artifact: CanvasArtifactContent) => void; + readonly openRoutine: (subject: RoutinePanelSubject) => void; + readonly toggleFocus: () => void; + /** Closes whichever content the canvas currently shows (profile, + * artifact, or routine) and drops focus — one seam regardless of what's + * open. */ + readonly close: () => void; +}; + +export const CanvasHostContext = createContext({ + allowed: false, + open: false, + profile: null, + artifact: null, + routine: null, + focus: false, + openProfile: () => undefined, + openArtifact: () => undefined, + openRoutine: () => undefined, + toggleFocus: () => undefined, + close: () => undefined, +}); diff --git a/apps/web/src/shell/composer-insertion-context.ts b/apps/web/src/shell/composer-insertion-context.ts new file mode 100644 index 000000000..23eaaefac --- /dev/null +++ b/apps/web/src/shell/composer-insertion-context.ts @@ -0,0 +1,16 @@ +// The composer-insertion context object, apart from the provider and hooks +// around it — see `../bench-context-value.ts` for why a `createContext` +// call never shares a module with a component. + +import { createContext } from "react"; + +export type ComposerInsertionHost = { + readonly registerInsert: (insert: ((text: string) => void) | null) => void; + /** Returns whether a composer was actually mounted to receive the text. */ + readonly insertText: (text: string) => boolean; +}; + +export const ComposerInsertionContext = createContext({ + registerInsert: () => undefined, + insertText: () => false, +}); diff --git a/apps/web/src/shell/composer-insertion.tsx b/apps/web/src/shell/composer-insertion.tsx index a50b3cfbd..202a422ee 100644 --- a/apps/web/src/shell/composer-insertion.tsx +++ b/apps/web/src/shell/composer-insertion.tsx @@ -6,19 +6,13 @@ // "shell context exposing a callback hook" shape `canvas-availability.tsx` // already uses for opening a profile. -import { createContext, useContext, useMemo, useRef } from "react"; +import { useContext, useMemo, useRef } from "react"; import type { ReactNode } from "react"; -export type ComposerInsertionHost = { - readonly registerInsert: (insert: ((text: string) => void) | null) => void; - /** Returns whether a composer was actually mounted to receive the text. */ - readonly insertText: (text: string) => boolean; -}; +import { ComposerInsertionContext } from "./composer-insertion-context"; +import type { ComposerInsertionHost } from "./composer-insertion-context"; -const ComposerInsertionContext = createContext({ - registerInsert: () => undefined, - insertText: () => false, -}); +export type { ComposerInsertionHost }; export function ComposerInsertionProvider({ children }: { readonly children: ReactNode }) { const insertRef = useRef<((text: string) => void) | null>(null);