diff --git a/apps/app/src/app/lib/den-session-events.ts b/apps/app/src/app/lib/den-session-events.ts deleted file mode 100644 index cee85b5067..0000000000 --- a/apps/app/src/app/lib/den-session-events.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { DenSettings, DenUser } from "./den"; - -export const denSessionUpdatedEvent = "openwork-den-session-updated"; -export const denSettingsChangedEvent = "openwork-den-settings-changed"; - -export type DenSessionUpdatedDetail = { - status?: "success" | "error" | "signed_out"; - baseUrl?: string | null; - token?: string | null; - user?: DenUser | null; - email?: string | null; - message?: string | null; -}; - -export function dispatchDenSessionUpdated(detail: DenSessionUpdatedDetail) { - if (typeof window === "undefined") { - return; - } - - window.dispatchEvent( - new CustomEvent(denSessionUpdatedEvent, { - detail, - }), - ); -} - -export type DenSettingsChangedDetail = { - settings: DenSettings; -}; - -export function dispatchDenSettingsChanged(detail: DenSettingsChangedDetail) { - if (typeof window === "undefined") { - return; - } - - window.dispatchEvent( - new CustomEvent(denSettingsChangedEvent, { - detail, - }), - ); -} diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index d7804da22b..9591e41c86 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -11,9 +11,7 @@ export type { SharedDesktopConfig }; export { normalizeDesktopConfig }; import { isDesktopDeployment } from "./openwork-deployment"; -import { - dispatchDenSettingsChanged, -} from "./den-session-events"; +import { events } from "@/lib/event-bus"; import { desktopFetch, getDesktopBootstrapConfig as getDesktopBootstrapConfigFromShell, @@ -630,7 +628,7 @@ export async function setDenBootstrapConfig( applyDesktopBootstrapConfig(normalized); } - dispatchDenSettingsChanged({ + events.emit("openwork-den-settings-changed", { settings: readDenSettings(), }); @@ -747,7 +745,7 @@ export function writeDenSettings(next: DenSettings, options?: { persistBootstrap } } - dispatchDenSettingsChanged({ + events.emit("openwork-den-settings-changed", { settings: readDenSettings(), }); } @@ -767,7 +765,7 @@ export function clearDenSession(options?: { includeBaseUrls?: boolean }) { window.localStorage.removeItem(STORAGE_ACTIVE_ORG_SLUG); window.localStorage.removeItem(STORAGE_ACTIVE_ORG_NAME); - dispatchDenSettingsChanged({ + events.emit("openwork-den-settings-changed", { settings: readDenSettings(), }); } diff --git a/apps/app/src/app/lib/provider-events.ts b/apps/app/src/app/lib/provider-events.ts deleted file mode 100644 index 7f16ba7fe5..0000000000 --- a/apps/app/src/app/lib/provider-events.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Global DOM event fired when new providers become available in the - * workspace — regardless of whether they came from cloud sync, local - * config changes, or manual setup. - * - * Listeners (e.g. the global NewProvidersToast) should use this to - * show a notification. - */ -export const newProvidersEvent = "openwork-new-providers-available"; - -export type NewProviderInfo = { - id: string; - name: string; - providerId: string; - firstModelId?: string; - firstModelName?: string; -}; - -export type NewProvidersEventDetail = { - providers: NewProviderInfo[]; - newProviderCount?: number; - newModelCount?: number; - /** Where the change originated. "sign_in" is suppressed by the toast - * because the onboarding page handles first-time notification. */ - source: "cloud_sync" | "local_config" | "models_refresh" | "sign_in"; -}; - -export function dispatchNewProviders(detail: NewProvidersEventDetail): void { - if (typeof window === "undefined") return; - window.dispatchEvent( - new CustomEvent(newProvidersEvent, { detail }), - ); -} diff --git a/apps/app/src/components/model-select.tsx b/apps/app/src/components/model-select.tsx index b0386536c1..ce6ba43b48 100644 --- a/apps/app/src/components/model-select.tsx +++ b/apps/app/src/components/model-select.tsx @@ -28,7 +28,6 @@ import { OPENWORK_MODEL_PREVIEWS, OPENWORK_MODELS_PROVIDER_ID, OPENWORK_MODELS_PROVIDER_NAME, - openWorkModelsPromoChangedEvent, } from "@/react-app/domains/cloud/openwork-models-promo"; import { getConnectedProviderItems, useProviderListQuery } from "@/react-app/domains/connections/provider-list-query"; import { @@ -43,8 +42,7 @@ import { CommandList, } from "@/components/ui/command"; import { isDesktopProviderBlocked } from "@/app/cloud/desktop-app-restrictions"; -import { openModelPickerEvent } from "@/react-app/shell/new-providers-toast"; -import { newProvidersEvent } from "@/app/lib/provider-events"; +import { events } from "@/lib/event-bus"; function getProviderDisplayName(providerId: string) { return providerId @@ -75,8 +73,7 @@ function useModelOptions(open: boolean) { const handler = () => { void refetch(); }; - window.addEventListener(newProvidersEvent, handler); - return () => window.removeEventListener(newProvidersEvent, handler); + return events.on("openwork-new-providers-available", handler); }, [client, refetch]); // Apply org-level restrictions (dev #1505) on top of the raw model list @@ -216,8 +213,7 @@ export function ModelSelect({ React.useEffect(() => { const handlePromoChanged = () => setPromoHidden(isOpenWorkModelsPromoHidden()); - window.addEventListener(openWorkModelsPromoChangedEvent, handlePromoChanged); - return () => window.removeEventListener(openWorkModelsPromoChangedEvent, handlePromoChanged); + return events.on("openwork-openwork-models-promo-changed", handlePromoChanged); }, []); const focusSearchInput = React.useCallback(() => { @@ -409,7 +405,7 @@ export function ModelSelect({ onClick={() => { onOpenChange(false); setSearch(""); - window.dispatchEvent(new CustomEvent(openModelPickerEvent)); + events.emit("openwork-open-model-picker"); }} > diff --git a/apps/app/src/lib/event-bus.ts b/apps/app/src/lib/event-bus.ts new file mode 100644 index 0000000000..c916ad4f88 --- /dev/null +++ b/apps/app/src/lib/event-bus.ts @@ -0,0 +1,81 @@ +import type { DenSettings, DenUser } from "@/app/lib/den"; +import type { OpenTarget } from "@/react-app/domains/session/artifacts/open-target"; + +export function createEventBus() { + const target = new EventTarget(); + + return { + on( + type: TType, + listener: (event: CustomEvent) => void, + options?: AddEventListenerOptions, + ) { + const wrapped: EventListenerObject = { + handleEvent(event: CustomEvent) { + listener(event); + }, + }; + + target.addEventListener(type, wrapped, options); + + return () => { + target.removeEventListener(type, wrapped, options); + }; + }, + + emit( + type: TType, + detail?: TEvents[TType], + options?: Omit, "detail">, + ) { + return target.dispatchEvent( + new CustomEvent(type, { + ...options, + detail, + }), + ); + }, + }; +} + +type NewProviderInfo = { + id: string; + name: string; + providerId: string; + firstModelId?: string; + firstModelName?: string; +}; + + +interface AppEvents { + "openwork-den-session-updated": { + status?: "success" | "error" | "signed_out"; + baseUrl?: string | null; + token?: string | null; + user?: DenUser | null; + email?: string | null; + message?: string | null; + }; + "openwork-den-settings-changed": { settings: DenSettings }; + "openwork-new-providers-available": { + providers: NewProviderInfo[]; + newProviderCount?: number; + newModelCount?: number; + source: "cloud_sync" | "local_config" | "models_refresh" | "sign_in"; + }; + "openwork-openwork-models-promo-changed": undefined; + "openwork-open-model-picker": { newProviderIds?: string[]; initialTab?: "default" | "available" } | undefined; + "openwork-org-onboarding-visibility": { visible?: boolean }; + "openwork-server-settings-changed": undefined; + "openwork:focusPrompt": undefined; + "openwork:flushPromptDraft": undefined; + "openwork:voice-transcript": { text: string }; + "openwork-open-accessible-target": OpenTarget; + "openwork-hide-accessible-target": OpenTarget; + "openwork-close-right-pane": undefined; +} + +export type InferAppEvent = CustomEvent; +export type InferAppEventDetails = AppEvents[TType]; + +export const events = createEventBus(); diff --git a/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx b/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx index 96a8ea54c7..19159327e3 100644 --- a/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx +++ b/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx @@ -19,16 +19,13 @@ import { writeDenSettings, type DenUser, } from "../../../app/lib/den"; -import { - denSessionUpdatedEvent, - dispatchDenSessionUpdated, -} from "../../../app/lib/den-session-events"; import { deepLinkBridgeEvent, drainPendingDeepLinks, type DeepLinkBridgeDetail, } from "../../../app/lib/deep-link-bridge"; import { parseDenAuthDeepLink } from "../../../app/lib/openwork-links"; +import { events } from "@/lib/event-bus"; export type DenAuthStatus = "checking" | "signed_in" | "signed_out"; @@ -119,10 +116,7 @@ export function DenAuthProvider({ children }: DenAuthProviderProps) { void refresh(); }; - window.addEventListener(denSessionUpdatedEvent, handleSessionUpdated); - return () => { - window.removeEventListener(denSessionUpdatedEvent, handleSessionUpdated); - }; + return events.on("openwork-den-session-updated", handleSessionUpdated); }, [refresh]); useEffect(() => { @@ -149,7 +143,7 @@ export function DenAuthProvider({ children }: DenAuthProviderProps) { activeOrgName: null, }); - dispatchDenSessionUpdated({ + events.emit("openwork-den-session-updated", { status: "success", baseUrl: parsed.denBaseUrl, token: result.token, @@ -159,7 +153,7 @@ export function DenAuthProvider({ children }: DenAuthProviderProps) { }) .catch((error) => { handledGrantsRef.current.delete(parsed.grant); - dispatchDenSessionUpdated({ + events.emit("openwork-den-session-updated", { status: "error", message: error instanceof Error diff --git a/apps/app/src/react-app/domains/cloud/desktop-config-provider.tsx b/apps/app/src/react-app/domains/cloud/desktop-config-provider.tsx index 5be9212d63..bef211b7a9 100644 --- a/apps/app/src/react-app/domains/cloud/desktop-config-provider.tsx +++ b/apps/app/src/react-app/domains/cloud/desktop-config-provider.tsx @@ -24,11 +24,8 @@ import { readDenSettings, type DenDesktopConfig, } from "../../../app/lib/den"; -import { - denSessionUpdatedEvent, - denSettingsChangedEvent, -} from "../../../app/lib/den-session-events"; import { useDenAuth } from "./den-auth-provider"; +import { events } from "@/lib/event-bus"; export type DesktopConfigStore = { config: DenDesktopConfig; @@ -252,12 +249,10 @@ export function DesktopConfigProvider({ children }: DesktopConfigProviderProps) useEffect(() => { if (typeof window === "undefined") return; - const handleSettingsChanged = () => { - bumpSettingsVersion(); - }; + const controller = new AbortController(); - window.addEventListener(denSessionUpdatedEvent, handleSettingsChanged); - window.addEventListener(denSettingsChangedEvent, handleSettingsChanged); + events.on("openwork-den-session-updated", () => bumpSettingsVersion(), { signal: controller.signal }); + events.on("openwork-den-settings-changed", () => bumpSettingsVersion(), { signal: controller.signal }); const interval = window.setInterval(() => { if (!isSignedIn) return; @@ -265,8 +260,7 @@ export function DesktopConfigProvider({ children }: DesktopConfigProviderProps) }, DESKTOP_CONFIG_REFRESH_MS); return () => { - window.removeEventListener(denSessionUpdatedEvent, handleSettingsChanged); - window.removeEventListener(denSettingsChangedEvent, handleSettingsChanged); + controller.abort(); window.clearInterval(interval); }; }, [desktopConfigHandler, isSignedIn]); diff --git a/apps/app/src/react-app/domains/cloud/forced-signin-page.tsx b/apps/app/src/react-app/domains/cloud/forced-signin-page.tsx index 9e673b55d0..56f5226742 100644 --- a/apps/app/src/react-app/domains/cloud/forced-signin-page.tsx +++ b/apps/app/src/react-app/domains/cloud/forced-signin-page.tsx @@ -14,16 +14,12 @@ import { setDenBootstrapConfig, writeDenSettings, } from "../../../app/lib/den"; -import { - denSessionUpdatedEvent, - dispatchDenSessionUpdated, - type DenSessionUpdatedDetail, -} from "../../../app/lib/den-session-events"; import { usePlatform } from "../../kernel/platform"; import { useBootState } from "../../shell/boot-state"; import { useDenAuth } from "./den-auth-provider"; import { useDesktopConfig } from "./desktop-config-provider"; import { DenSignInSurface } from "./den-signin-surface"; +import { events } from "@/lib/event-bus"; export type ForcedSigninPageProps = { developerMode: boolean; @@ -147,7 +143,7 @@ export function ForcedSigninPage({ developerMode }: ForcedSigninPageProps) { setManualAuthInput(""); setManualAuthOpen(false); - dispatchDenSessionUpdated({ + events.emit("openwork-den-session-updated", { status: "success", baseUrl: nextBaseUrl, token: result.token, @@ -155,7 +151,7 @@ export function ForcedSigninPage({ developerMode }: ForcedSigninPageProps) { email: result.user?.email ?? null, }); } catch (error) { - dispatchDenSessionUpdated({ + events.emit("openwork-den-session-updated", { status: "error", message: error instanceof Error @@ -223,38 +219,29 @@ export function ForcedSigninPage({ developerMode }: ForcedSigninPageProps) { useEffect(() => { if (typeof window === "undefined") return; - const handler = (event: Event) => { - const customEvent = event as CustomEvent; + return events.on("openwork-den-session-updated", (event) => { const nextSettings = readDenSettings(); const nextBaseUrl = - customEvent.detail?.baseUrl?.trim() || + event.detail?.baseUrl?.trim() || nextSettings.baseUrl || DEFAULT_DEN_BASE_URL; setBaseUrl(nextBaseUrl); setBaseUrlDraft(nextBaseUrl); - if (customEvent.detail?.status === "success") { + if (event.detail?.status === "success") { setAuthError(null); - const email = customEvent.detail.email?.trim(); + const email = event.detail.email?.trim(); setStatusMessage( email ? t("den.status_cloud_signed_in_as", { email }) : t("den.status_cloud_signin_done"), ); - } else if (customEvent.detail?.status === "error") { + } else if (event.detail?.status === "error") { setAuthError( - customEvent.detail.message?.trim() || t("den.error_signin_failed"), + event.detail.message?.trim() || t("den.error_signin_failed"), ); } - }; - - window.addEventListener(denSessionUpdatedEvent, handler as EventListener); - return () => { - window.removeEventListener( - denSessionUpdatedEvent, - handler as EventListener, - ); - }; + }); }, []); return ( diff --git a/apps/app/src/react-app/domains/cloud/openwork-models-promo.ts b/apps/app/src/react-app/domains/cloud/openwork-models-promo.ts index dd28748499..420df2f5ee 100644 --- a/apps/app/src/react-app/domains/cloud/openwork-models-promo.ts +++ b/apps/app/src/react-app/domains/cloud/openwork-models-promo.ts @@ -1,5 +1,6 @@ import { INFERENCE_MODEL_ALIASES } from "@openwork/types/den/inference"; +import { events } from "@/lib/event-bus"; import { buildDenAuthUrl, getDenInferenceUrl, @@ -11,7 +12,6 @@ export const OPENWORK_MODELS_PROVIDER_ID = "openwork"; export const OPENWORK_MODELS_PROVIDER_NAME = "OpenWork Models"; export const OPENWORK_MODELS_PROMO_HIDDEN_KEY = "openwork.openworkModelsPromo.hidden"; export const OPENWORK_MODELS_PROMO_LAST_SHOWN_KEY = "openwork.openworkModelsPromo.lastShownAt"; -export const openWorkModelsPromoChangedEvent = "openwork-openwork-models-promo-changed"; export const OPENWORK_MODELS_PROMO_SHOW_DELAY_MS = 4_000; export const OPENWORK_MODELS_PROMO_VISIBLE_MS = 14_000; export const OPENWORK_MODELS_PROMO_REPEAT_MS = 6 * 60 * 60 * 1000; @@ -55,7 +55,7 @@ export function hideOpenWorkModelsPromo() { if (typeof window === "undefined") return; try { window.localStorage.setItem(OPENWORK_MODELS_PROMO_HIDDEN_KEY, "1"); - window.dispatchEvent(new Event(openWorkModelsPromoChangedEvent)); + events.emit("openwork-openwork-models-promo-changed"); } catch {} } diff --git a/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx b/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx index d9790776ae..173e6361b1 100644 --- a/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx +++ b/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx @@ -30,7 +30,6 @@ import { useBootState } from "../../shell/boot-state"; import { resolveModelDisplayName, resolveProviderDisplayName } from "@/app/utils"; import { ProviderIcon } from "../../design-system/provider-icon"; import { writeStoredDefaultModel } from "../../kernel/model-config"; -import { orgOnboardingVisibilityEvent } from "../../shell/reload-coordinator"; import { Page, PageBackground, @@ -62,6 +61,7 @@ import { RadioGroup, RadioGroupItem, } from "@/components/ui/radio-group" +import { events } from "@/lib/event-bus"; const RELOAD_AFTER_ONBOARDING_KEY = "openwork.reloadAfterOrgOnboarding"; @@ -113,9 +113,9 @@ export function OrgOnboardingPage() { const [hasSelectedOrganization, setHasSelectedOrganization] = useState(false); useEffect(() => { - window.dispatchEvent(new CustomEvent(orgOnboardingVisibilityEvent, { detail: { visible: true } })); + events.emit("openwork-org-onboarding-visibility", { visible: true }); return () => { - window.dispatchEvent(new CustomEvent(orgOnboardingVisibilityEvent, { detail: { visible: false } })); + events.emit("openwork-org-onboarding-visibility", { visible: false }); }; }, []); diff --git a/apps/app/src/react-app/domains/cloud/use-cloud-provider-auto-sync.ts b/apps/app/src/react-app/domains/cloud/use-cloud-provider-auto-sync.ts index 5c2463bf36..5cb7ad6efc 100644 --- a/apps/app/src/react-app/domains/cloud/use-cloud-provider-auto-sync.ts +++ b/apps/app/src/react-app/domains/cloud/use-cloud-provider-auto-sync.ts @@ -2,8 +2,8 @@ import { useEffect, useRef } from "react"; import { CLOUD_SYNC_INTERVAL_MS } from "../../../app/cloud/sync/constants"; -import { denSettingsChangedEvent } from "../../../app/lib/den-session-events"; import { useDenAuth } from "./den-auth-provider"; +import { events } from "@/lib/event-bus"; type CloudProviderSyncReason = "sign_in" | "app_launch" | "interval" | "settings_cloud_opened"; type SyncFn = (reason: CloudProviderSyncReason) => Promise; @@ -56,7 +56,7 @@ export function useCloudProviderAutoSync(sync: SyncFn) { const handleDenSettingsChanged = () => { void tick("sign_in"); }; - window.addEventListener(denSettingsChangedEvent, handleDenSettingsChanged); + const stopSettingsUpdates = events.on("openwork-den-settings-changed", handleDenSettingsChanged); const interval = window.setInterval(() => { void tick(); @@ -64,7 +64,7 @@ export function useCloudProviderAutoSync(sync: SyncFn) { return () => { cancelled = true; - window.removeEventListener(denSettingsChangedEvent, handleDenSettingsChanged); + stopSettingsUpdates(); window.clearInterval(interval); }; }, [denAuth.isSignedIn]); diff --git a/apps/app/src/react-app/domains/connections/provider-auth/store.ts b/apps/app/src/react-app/domains/connections/provider-auth/store.ts index 36cbec89d1..372875d995 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/store.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/store.ts @@ -34,21 +34,17 @@ import { import { getReactQueryClient } from "../../../infra/query-client"; import { ensureProviderListQuery } from "../provider-list-query"; import type { OpenworkServerStore } from "../openwork-server-store"; -import { - denSessionUpdatedEvent, - type DenSessionUpdatedDetail, -} from "../../../../app/lib/den-session-events"; import { readWorkspaceCloudImports, withWorkspaceCloudImports, type CloudImportedProvider, } from "../../../../app/cloud/import-state"; import { refreshDesktopCloudSync } from "../../../../app/cloud/desktop-cloud-sync"; -import { dispatchNewProviders } from "../../../../app/lib/provider-events"; import { isDesktopProviderBlocked, type DesktopAppRestrictionChecker, } from "../../../../app/cloud/desktop-app-restrictions"; +import { events } from "@/lib/event-bus"; type ProviderReturnFocusTarget = "none" | "composer"; type CloudProviderSyncReason = "sign_in" | "app_launch" | "interval" | "settings_cloud_opened"; @@ -888,7 +884,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) : undefined, }; }); - dispatchNewProviders({ providers: infos, source: "local_config" }); + events.emit("openwork-new-providers-available", { providers: infos, source: "local_config" }); } } }; @@ -1581,7 +1577,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) // Notify the UI about newly imported providers so the global toast // can be shown regardless of which route is active. if (newlyImported.length > 0) { - dispatchNewProviders({ + events.emit("openwork-new-providers-available", { providers: newlyImported, source: reason === "sign_in" ? "sign_in" : "cloud_sync", }); @@ -1742,11 +1738,11 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) started = true; lastWorkspaceKey = currentWorkspaceKey(); if (typeof window !== "undefined") { - const handleDenSessionUpdate = (event: Event) => { + const handleDenSessionUpdate = (event: CustomEvent<{ status?: "success" | "error" | "signed_out" }>) => { cloudOrgProvidersLoadKey = ""; cloudOrgProvidersInFlightKey = ""; cloudOrgProvidersInFlight = null; - const detail = (event as CustomEvent).detail; + const detail = event.detail; if (detail?.status === "success") { mutateState((current) => ({ @@ -1801,16 +1797,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) })(); } }; - window.addEventListener( - denSessionUpdatedEvent, - handleDenSessionUpdate as EventListener, - ); - denSessionCleanup = () => { - window.removeEventListener( - denSessionUpdatedEvent, - handleDenSessionUpdate as EventListener, - ); - }; + denSessionCleanup = events.on("openwork-den-session-updated", handleDenSessionUpdate); } void refreshImportedCloudProviders().then((imported) => { // Startup cleanup: if no auth token, remove any cloud providers that diff --git a/apps/app/src/react-app/domains/connections/provider-list-query.ts b/apps/app/src/react-app/domains/connections/provider-list-query.ts index 053418d921..2e8452059c 100644 --- a/apps/app/src/react-app/domains/connections/provider-list-query.ts +++ b/apps/app/src/react-app/domains/connections/provider-list-query.ts @@ -2,7 +2,7 @@ import { useQuery, type QueryClient } from "@tanstack/react-query"; import type { Client, ModelRef, ProviderListItem } from "../../../app/types"; import { unwrap } from "../../../app/lib/opencode"; -import { dispatchNewProviders } from "../../../app/lib/provider-events"; +import { events } from "@/lib/event-bus"; import type { ProviderListResponse } from "@opencode-ai/sdk/v2/client"; export const PROVIDER_LIST_CACHE_MS = 5 * 60 * 1000; @@ -145,7 +145,7 @@ function dispatchConnectedProviderChanges( if (newProviders.length === 0 && newModelCount === 0) return; - dispatchNewProviders({ + events.emit("openwork-new-providers-available", { providers: [...changedProviders.values()].map((provider) => { const firstModelId = Object.keys(provider.models)[0]; return { diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 2776074625..c3d47bc43e 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -61,6 +61,7 @@ import { useWorkspaceShellLayout } from "../../../shell/workspace-shell-layout"; import { useControlAction, type OpenworkControlAction } from "../../../shell/control/control-provider"; import { getExtensionId, isOpenWorkExtensionEnabled, OPENWORK_EXTENSION_STATE_CHANGED } from "../../settings/extension-state"; import { cn } from "@/lib/utils"; +import { events } from "@/lib/event-bus"; const STARTUP_SKELETON_ROWS = [ { id: "intro", titleWidth: "42%", bodyWidth: "88%" }, @@ -439,29 +440,31 @@ export function SessionPage(props: SessionPageProps) { } }, [closeTab, hiddenAccessibleTargetIds, props.selectedSessionId, props.selectedWorkspaceId]); useEffect(() => { - const open = (event: Event) => { - const requested = (event as CustomEvent).detail; + const controller = new AbortController(); + + events.on("openwork-open-accessible-target", ({ detail: requested }) => { const target = accessibleTargets.find((item) => item.id === requested?.id || item.value === requested?.value) ?? ( requested?.kind && requested?.value ? requested : null ); - if (target) openTarget(target); - }; - const hide = (event: Event) => { - const requested = (event as CustomEvent).detail; + + if (target) { + openTarget(target); + } + }, { signal: controller.signal }); + + events.on("openwork-hide-accessible-target", ({ detail: requested }) => { const target = accessibleTargets.find((item) => item.id === requested?.id || item.value === requested?.value); - if (target) removeAccessibleTarget(target); - }; - window.addEventListener("openwork-open-accessible-target", open); - window.addEventListener("openwork-hide-accessible-target", hide); - return () => { - window.removeEventListener("openwork-open-accessible-target", open); - window.removeEventListener("openwork-hide-accessible-target", hide); - }; + + if (target) { + removeAccessibleTarget(target); + } + }, { signal: controller.signal }); + + return () => controller.abort(); }, [accessibleTargets, openTarget, removeAccessibleTarget]); useEffect(() => { const handler = () => setCurrentSidePanel(null); - window.addEventListener("openwork-close-right-pane", handler); - return () => window.removeEventListener("openwork-close-right-pane", handler); + return events.on("openwork-close-right-pane", handler); }, [setCurrentSidePanel]); useEffect(() => { const refresh = () => setExtensionStateVersion((value) => value + 1); diff --git a/apps/app/src/react-app/domains/session/chat/status-bar.tsx b/apps/app/src/react-app/domains/session/chat/status-bar.tsx index c61c090501..4709594bc9 100644 --- a/apps/app/src/react-app/domains/session/chat/status-bar.tsx +++ b/apps/app/src/react-app/domains/session/chat/status-bar.tsx @@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { events } from "@/lib/event-bus"; import { cn } from "@/lib/utils"; import { t } from "@/i18n"; import { buildDenAuthUrl, readDenBootstrapConfig } from "@/app/lib/den"; @@ -21,7 +22,6 @@ import { markOpenWorkModelsPromoShown, OPENWORK_MODELS_PROMO_SHOW_DELAY_MS, OPENWORK_MODELS_PROMO_VISIBLE_MS, - openWorkModelsPromoChangedEvent, shouldShowOpenWorkModelsPromo, } from "../../cloud/openwork-models-promo"; @@ -179,8 +179,7 @@ export function StatusBar(props: StatusBarProps) { setOpenWorkModelsHintVisible(false); } }; - window.addEventListener(openWorkModelsPromoChangedEvent, handlePromoChanged); - return () => window.removeEventListener(openWorkModelsPromoChangedEvent, handlePromoChanged); + return events.on("openwork-openwork-models-promo-changed", handlePromoChanged); }, []); useEffect(() => { diff --git a/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx b/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx index 55913ca28d..a04156b63a 100644 --- a/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx +++ b/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx @@ -19,6 +19,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { events } from "@/lib/event-bus"; import { modelEquals, resolveProviderDisplayName } from "../../../../app/utils"; import type { ModelOption, ModelRef } from "../../../../app/types"; import { isRecommendedModel } from "../../../../app/defaults"; @@ -32,7 +33,6 @@ import { isOpenWorkModelsPromoHidden, OPENWORK_MODELS_PROVIDER_ID, OPENWORK_MODELS_PROVIDER_NAME, - openWorkModelsPromoChangedEvent, } from "../../cloud/openwork-models-promo"; export type ModelPickerModalProps = { @@ -83,8 +83,7 @@ export function ModelPickerModal(props: ModelPickerModalProps) { useEffect(() => { const handlePromoChanged = () => setPromoHidden(isOpenWorkModelsPromoHidden()); - window.addEventListener(openWorkModelsPromoChangedEvent, handlePromoChanged); - return () => window.removeEventListener(openWorkModelsPromoChangedEvent, handlePromoChanged); + return events.on("openwork-openwork-models-promo-changed", handlePromoChanged); }, []); // Focus search diff --git a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx index 458209c70e..67517f7d57 100644 --- a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx +++ b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx @@ -16,6 +16,7 @@ import { ReactComposerNotice, type ReactComposerNotice as ReactComposerNoticeData, } from "./notice"; +import { events } from "@/lib/event-bus"; type MentionItem = { id: string; @@ -101,8 +102,6 @@ type ComposerProps = { topAccessory?: ReactNode; }; -const FLUSH_PROMPT_EVENT = "openwork:flushPromptDraft"; -const FOCUS_PROMPT_EVENT = "openwork:focusPrompt"; const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; const IMAGE_COMPRESS_MAX_PX = 2048; const IMAGE_COMPRESS_QUALITY = 0.82; @@ -795,16 +794,15 @@ export function ReactSessionComposer(props: ComposerProps) { // draft so downstream stores can checkpoint it. props.onDraftChange(draftRef.current); }; - window.addEventListener(FOCUS_PROMPT_EVENT, handleFocus); - window.addEventListener(FLUSH_PROMPT_EVENT, handleFlush); - window.addEventListener("beforeunload", handleFlush); - window.addEventListener("pagehide", handleFlush); - return () => { - window.removeEventListener(FOCUS_PROMPT_EVENT, handleFocus); - window.removeEventListener(FLUSH_PROMPT_EVENT, handleFlush); - window.removeEventListener("beforeunload", handleFlush); - window.removeEventListener("pagehide", handleFlush); - }; + + const controller = new AbortController(); + + events.on("openwork:focusPrompt", handleFocus, { signal: controller.signal }); + events.on("openwork:flushPromptDraft", handleFlush, { signal: controller.signal }); + window.addEventListener("beforeunload", handleFlush, { signal: controller.signal }); + window.addEventListener("pagehide", handleFlush, { signal: controller.signal }); + + return () => controller.abort(); }, [props.onDraftChange]); const handleKeyDownCapture: React.KeyboardEventHandler = (event) => { diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index b46636dae1..f203507831 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -43,6 +43,7 @@ import { deriveRenderedSessionMessages, resolveRenderedSessionSnapshot } from ". import { useLocal } from "../../../kernel/local-provider"; import { deriveSessionRenderModel } from "../sync/transition-controller"; import { useSessionScrollController } from "./scroll-controller"; +import { events } from "@/lib/event-bus"; import { SessionScrollOverlay } from "./scroll-overlay"; import { getSessionActivityStatusLabel, useSessionActivityStore, type SessionActivityStatus } from "../status/session-activity-store"; import { PermissionApprovalPanel } from "../chat/permission-approval-modal"; @@ -920,17 +921,14 @@ export function SessionSurface(props: SessionSurfaceProps) { }; const typeComposerText = useCallback(async (text: string) => { - window.dispatchEvent(new Event("openwork:focusPrompt")); + events.emit("openwork:focusPrompt"); setComposerDraft(props.sessionId, text); await waitForControl(40); }, [props.sessionId, setComposerDraft]); useEffect(() => { - const handleVoiceTranscript = (event: Event) => { - if (!(event instanceof CustomEvent)) return; - const detail: unknown = event.detail; - if (!detail || typeof detail !== "object" || Array.isArray(detail) || !("text" in detail) || typeof detail.text !== "string") return; - const text = detail.text; + const handleVoiceTranscript = (event: CustomEvent<{ text: string }>) => { + const text = event.detail.text; void typeComposerText(text); props.onDraftChange(buildDraft(text, attachments)); recordInspectorEvent("voice.transcript.applied", { @@ -939,8 +937,7 @@ export function SessionSurface(props: SessionSurfaceProps) { length: text.length, }); }; - window.addEventListener("openwork:voice-transcript", handleVoiceTranscript); - return () => window.removeEventListener("openwork:voice-transcript", handleVoiceTranscript); + return events.on("openwork:voice-transcript", handleVoiceTranscript); }, [attachments, buildDraft, props.onDraftChange, props.sessionId, props.workspaceId, typeComposerText]); const composerSetTextControlAction = useMemo(() => ({ diff --git a/apps/app/src/react-app/domains/session/sync/actions-store.ts b/apps/app/src/react-app/domains/session/sync/actions-store.ts index 070f3f4d06..16585f8096 100644 --- a/apps/app/src/react-app/domains/session/sync/actions-store.ts +++ b/apps/app/src/react-app/domains/session/sync/actions-store.ts @@ -32,6 +32,7 @@ import type { } from "../../../../app/types"; import { addOpencodeCacheHint, safeStringify } from "../../../../app/utils"; import { clearSessionDraft, saveSessionDraft } from "./draft-store"; +import { events } from "@/lib/event-bus"; type SessionModelConfig = { applyPendingSessionChoice: (sessionId: string) => void; @@ -48,8 +49,6 @@ type SessionActionsSnapshot = { sessionAgentById: Record; }; -const FLUSH_PROMPT_EVENT = "openwork:flushPromptDraft"; - const fileToDataUrl = (file: File, mimeType: string) => new Promise((resolve, reject) => { const reader = new FileReader(); @@ -477,9 +476,7 @@ export function createSessionActionsStore(options: { } async function createSessionAndOpen(initialPrompt?: string) { - if (typeof window !== "undefined") { - window.dispatchEvent(new CustomEvent(FLUSH_PROMPT_EVENT)); - } + events.emit("openwork:flushPromptDraft"); const workspaceId = options.selectedWorkspaceId().trim(); if (!workspaceId) { return undefined; diff --git a/apps/app/src/react-app/domains/session/voice/voice-panel.tsx b/apps/app/src/react-app/domains/session/voice/voice-panel.tsx index 74283858b7..478c404f0c 100644 --- a/apps/app/src/react-app/domains/session/voice/voice-panel.tsx +++ b/apps/app/src/react-app/domains/session/voice/voice-panel.tsx @@ -12,6 +12,7 @@ import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; import { publishInspectorSlice, recordInspectorEvent } from "../../../shell/app-inspector"; import { useControlAction, type OpenworkControlAction } from "../../../shell/control/control-provider"; +import { events } from "@/lib/event-bus"; type VoiceStatus = "idle" | "connecting" | "listening" | "muted" | "speaking" | "error"; @@ -619,7 +620,7 @@ export function VoicePanel(props: VoicePanelProps) { const text = voiceTextArgument(args); setVoiceRuntimeSnapshot((current) => ({ ...current, latestUserTranscript: text })); addEntry("user", text); - window.dispatchEvent(new CustomEvent("openwork:voice-transcript", { detail: { text } })); + events.emit("openwork:voice-transcript", { text }); recordInspectorEvent("voice.inject_transcript", { sessionId: props.sessionId, text }); return { ok: true, transcript: text }; }, [addEntry, props.sessionId]); diff --git a/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx b/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx index 3dcdc56260..f20f9ea356 100644 --- a/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx +++ b/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx @@ -9,7 +9,7 @@ import { type DenOrgSummary, type DenUser, } from "../../../../app/lib/den"; -import { denSettingsChangedEvent } from "../../../../app/lib/den-session-events"; +import { events } from "@/lib/event-bus"; type CloudActiveOrganization = Pick; @@ -67,8 +67,7 @@ export function CloudSessionProvider({ children }: CloudSessionProviderProps) { setApiBaseUrl(readDenSettings().apiBaseUrl || ""); }; - window.addEventListener(denSettingsChangedEvent, handleSettingsChanged); - return () => window.removeEventListener(denSettingsChangedEvent, handleSettingsChanged); + return events.on("openwork-den-settings-changed", handleSettingsChanged); }, []); const client = React.useMemo( diff --git a/apps/app/src/react-app/domains/settings/cloud/use-den-session.tsx b/apps/app/src/react-app/domains/settings/cloud/use-den-session.tsx index 60ff83a7cb..a21ddc4365 100644 --- a/apps/app/src/react-app/domains/settings/cloud/use-den-session.tsx +++ b/apps/app/src/react-app/domains/settings/cloud/use-den-session.tsx @@ -14,23 +14,14 @@ import { writeDenSettings, type DenOrgSummary, } from "../../../../app/lib/den"; -import { - denSessionUpdatedEvent, - dispatchDenSessionUpdated, - type DenSessionUpdatedDetail, -} from "../../../../app/lib/den-session-events"; import { t } from "@/i18n"; import { useStatusToasts } from "../../shell-feedback/status-toasts"; import { useCloudSession } from "./cloud-session-provider"; +import { events, type InferAppEventDetails } from "@/lib/event-bus"; +type DenSessionUpdatedDetail = InferAppEventDetails<"openwork-den-session-updated">; type SettingsTone = "ready" | "warning" | "neutral" | "error"; -declare global { - interface WindowEventMap { - "openwork-den-session-updated": CustomEvent; - } -} - export type UseDenSessionProps = { developerMode: boolean; openLink: (url: string) => void; @@ -184,7 +175,7 @@ export function useDenSession({ } } catch {} // Notify provider auth store so it can clean up cloud-imported providers - dispatchDenSessionUpdated({ status: "signed_out", ...eventDetail }); + events.emit("openwork-den-session-updated", { status: "signed_out", ...eventDetail }); }, [clearSessionState, developerMode, setAuthToken, setBaseUrl], ); @@ -344,7 +335,7 @@ export function useDenSession({ }, [refreshOrgs, user]); React.useEffect(() => { - const handler = (event: WindowEventMap[typeof denSessionUpdatedEvent]) => { + return events.on("openwork-den-session-updated", (event) => { const nextSettings = readDenSettings(); const nextBaseUrl = event.detail?.baseUrl?.trim() || nextSettings.baseUrl || DEFAULT_DEN_BASE_URL; @@ -369,10 +360,7 @@ export function useDenSession({ } else if (event.detail?.status === "error") { setAuthError(event.detail.message?.trim() || t("den.error_signin_failed")); } - }; - - window.addEventListener(denSessionUpdatedEvent, handler); - return () => window.removeEventListener(denSessionUpdatedEvent, handler); + }); }, [clearSessionState, setAuthToken, setBaseUrl]); const submitManualAuth = React.useCallback(async (input: string) => { @@ -406,7 +394,7 @@ export function useDenSession({ activeOrgName: null, }); - dispatchDenSessionUpdated({ + events.emit("openwork-den-session-updated", { status: "success", baseUrl: nextBaseUrl, token: result.token, @@ -415,7 +403,7 @@ export function useDenSession({ }); return true; } catch (error) { - dispatchDenSessionUpdated({ + events.emit("openwork-den-session-updated", { status: "error", message: error instanceof Error ? error.message : t("den.error_signin_failed"), }); diff --git a/apps/app/src/react-app/domains/settings/pages/skills-view.tsx b/apps/app/src/react-app/domains/settings/pages/skills-view.tsx index 7af0b76d6d..bbcfb43149 100644 --- a/apps/app/src/react-app/domains/settings/pages/skills-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/skills-view.tsx @@ -61,6 +61,7 @@ import { SelectMenu, type SelectMenuOption, } from "../../../design-system/select-menu"; +import { events } from "@/lib/event-bus"; type InstallResult = { ok: boolean; message: string }; type SkillsFilter = "all" | "installed" | "cloud" | "hub"; @@ -332,8 +333,7 @@ export function SkillsView(props: SkillsViewProps) { dispatchLocal({ type: "denSessionUpdated" }); void extensions.refreshCloudOrgSkills({ force: true }); }; - window.addEventListener("openwork-den-session-updated", onDenSession); - return () => window.removeEventListener("openwork-den-session-updated", onDenSession); + return events.on("openwork-den-session-updated", onDenSession); }, [extensions]); useEffect(() => { diff --git a/apps/app/src/react-app/domains/settings/state/debug-view-model.ts b/apps/app/src/react-app/domains/settings/state/debug-view-model.ts index 03962e6f59..34c77164f4 100644 --- a/apps/app/src/react-app/domains/settings/state/debug-view-model.ts +++ b/apps/app/src/react-app/domains/settings/state/debug-view-model.ts @@ -39,6 +39,7 @@ import { t } from "../../../../i18n"; import type { DebugViewProps } from "../pages/debug-view"; import type { ReleaseChannel } from "../../../../app/types"; import type { OpenworkServerStore, OpenworkServerStoreSnapshot } from "../../connections/openwork-server-store"; +import { events } from "@/lib/event-bus"; const STARTUP_PREFERENCE_KEY = "openwork.startupPreference"; const ENGINE_SOURCE_KEY = "openwork.engineSource"; @@ -698,9 +699,7 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) { portOverride: hostInfo.port ?? undefined, remoteAccessEnabled: hostInfo.remoteAccessEnabled === true, }); - if (typeof window !== "undefined") { - window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); - } + events.emit("openwork-server-settings-changed"); } } catch { // best-effort: if this fails, the host-info poller will catch up in ~10s. diff --git a/apps/app/src/react-app/domains/settings/state/extensions-store.ts b/apps/app/src/react-app/domains/settings/state/extensions-store.ts index d7b394bf07..52d1acf5fe 100644 --- a/apps/app/src/react-app/domains/settings/state/extensions-store.ts +++ b/apps/app/src/react-app/domains/settings/state/extensions-store.ts @@ -64,6 +64,7 @@ import { } from "../../../../app/cloud/import-state"; import { refreshDesktopCloudSync } from "../../../../app/cloud/desktop-cloud-sync"; import type { OpenworkServerStore } from "../../connections/openwork-server-store"; +import { events } from "@/lib/event-bus"; const OPENCODE_SKILL_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; const OPENCODE_MCP_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/; @@ -2762,8 +2763,7 @@ export function createExtensionsStore(options: { cloudOrgMarketplacesLoaded = false; mutateState((current) => ({ ...current, cloudOrgSkillsContextKey: "" })); }; - window.addEventListener("openwork-den-session-updated", onDenSessionUpdated); - stopDenSessionListener = () => window.removeEventListener("openwork-den-session-updated", onDenSessionUpdated); + stopDenSessionListener = events.on("openwork-den-session-updated", onDenSessionUpdated); } stopOpenworkSubscription = options.openworkServer.subscribe(() => { diff --git a/apps/app/src/react-app/shell/app-root.tsx b/apps/app/src/react-app/shell/app-root.tsx index d84912418f..c44a01caf3 100644 --- a/apps/app/src/react-app/shell/app-root.tsx +++ b/apps/app/src/react-app/shell/app-root.tsx @@ -4,7 +4,6 @@ import { useEffect, useSyncExternalStore, type ReactNode } from "react"; import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { readDenBootstrapConfig, readDenSettings } from "../../app/lib/den"; -import { denSettingsChangedEvent, denSessionUpdatedEvent } from "../../app/lib/den-session-events"; import { useDenAuth } from "../domains/cloud/den-auth-provider"; import { ForcedSigninPage } from "../domains/cloud/forced-signin-page"; import { OrgOnboardingPage } from "../domains/cloud/org-onboarding-page"; @@ -19,6 +18,7 @@ import { SessionRoute } from "./session-route"; import { SettingsRoute } from "./settings-route"; import { ShellConfigProvider } from "./shell-config"; import { WelcomeRoute } from "./welcome-route"; +import { events } from "@/lib/event-bus"; type DenSigninGateProps = { @@ -28,11 +28,7 @@ type DenSigninGateProps = { const readRequireSigninSnapshot = () => readDenBootstrapConfig().requireSignin; const subscribeToRequireSignin = (onStoreChange: () => void) => { - if (typeof window === "undefined") return () => {}; - window.addEventListener(denSettingsChangedEvent, onStoreChange); - return () => { - window.removeEventListener(denSettingsChangedEvent, onStoreChange); - }; + return events.on("openwork-den-settings-changed", onStoreChange); }; /** @@ -95,7 +91,7 @@ function DenSigninGate({ children }: DenSigninGateProps) { // Poll for activeOrgId (set asynchronously by refreshOrgs) rather // than using a fixed delay — handles both fast and slow org lookups. useEffect(() => { - const handler = (event: WindowEventMap[typeof denSessionUpdatedEvent]) => { + return events.on("openwork-den-session-updated", (event) => { if (event.detail?.status !== "success") return; let attempts = 0; const check = () => { @@ -110,9 +106,7 @@ function DenSigninGate({ children }: DenSigninGateProps) { }; // First check after a short delay for the auth to settle setTimeout(check, 500); - }; - window.addEventListener(denSessionUpdatedEvent, handler); - return () => window.removeEventListener(denSessionUpdatedEvent, handler); + }); }, [navigate]); if (requireSignin && denAuth.status === "checking") { diff --git a/apps/app/src/react-app/shell/command-palette.tsx b/apps/app/src/react-app/shell/command-palette.tsx index 58190dc378..60a6a3318c 100644 --- a/apps/app/src/react-app/shell/command-palette.tsx +++ b/apps/app/src/react-app/shell/command-palette.tsx @@ -24,6 +24,7 @@ import { } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; import { ChevronLeftIcon, FileText, Globe } from "lucide-react"; +import type { OpenTarget } from "../domains/session/artifacts/open-target"; export type PaletteItem = { id: string; @@ -35,13 +36,7 @@ export type PaletteItem = { action: () => void; }; -export type AccessibleTargetOption = { - id: string; - kind: "url" | "file"; - value: string; - name: string; - preview: string; -}; +export type AccessibleTargetOption = OpenTarget; type PaletteMode = "root" | "sessions" | "accessible-items"; diff --git a/apps/app/src/react-app/shell/desktop-local-openwork.ts b/apps/app/src/react-app/shell/desktop-local-openwork.ts index 0175a856a3..2dd1558dcd 100644 --- a/apps/app/src/react-app/shell/desktop-local-openwork.ts +++ b/apps/app/src/react-app/shell/desktop-local-openwork.ts @@ -8,6 +8,7 @@ import { import { readOpenworkServerSettings, writeOpenworkServerSettings } from "../../app/lib/openwork-server"; import { safeStringify } from "../../app/utils"; import { recordInspectorEvent } from "./app-inspector"; +import { events } from "@/lib/event-bus"; type LocalWorkspaceLike = { id: string; @@ -24,11 +25,7 @@ type EnsureDesktopLocalOpenworkOptions = { }; function emitOpenworkSettingsChanged() { - try { - window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); - } catch { - // ignore browser event dispatch failures - } + events.emit("openwork-server-settings-changed"); } function describeError(error: unknown) { diff --git a/apps/app/src/react-app/shell/desktop-runtime-boot.ts b/apps/app/src/react-app/shell/desktop-runtime-boot.ts index b8b02d0a5c..8892afa342 100644 --- a/apps/app/src/react-app/shell/desktop-runtime-boot.ts +++ b/apps/app/src/react-app/shell/desktop-runtime-boot.ts @@ -25,6 +25,7 @@ import { import { isDesktopRuntime, isElectronRuntime, safeStringify } from "../../app/utils"; import { useServer } from "../kernel/server-provider"; import { useBootState } from "./boot-state"; +import { events } from "@/lib/event-bus"; // Module-scoped latch so React Strict-Mode's "mount-unmount-remount" cycle in // dev only triggers the boot sequence once per app launch, and the async work @@ -108,7 +109,7 @@ export function useDesktopRuntimeBoot() { remoteAccessEnabled: serverInfo.remoteAccessEnabled === true, }); try { - window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); + events.emit("openwork-server-settings-changed"); } catch { /* ignore */ } @@ -211,9 +212,7 @@ export function useDesktopRuntimeBoot() { remoteAccessEnabled: fresh.remoteAccessEnabled === true, }); try { - window.dispatchEvent( - new CustomEvent("openwork-server-settings-changed"), - ); + events.emit("openwork-server-settings-changed"); } catch { /* ignore */ } @@ -301,7 +300,7 @@ export function useDesktopRuntimeBoot() { remoteAccessEnabled: freshInfo.remoteAccessEnabled === true, }); try { - window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); + events.emit("openwork-server-settings-changed"); } catch { /* ignore */ } diff --git a/apps/app/src/react-app/shell/new-providers-toast.tsx b/apps/app/src/react-app/shell/new-providers-toast.tsx index 9d148e733d..3d13293eb9 100644 --- a/apps/app/src/react-app/shell/new-providers-toast.tsx +++ b/apps/app/src/react-app/shell/new-providers-toast.tsx @@ -2,19 +2,15 @@ import { useCallback, useEffect, useState } from "react"; import { Zap, X } from "lucide-react"; import { resolveProviderDisplayName } from "../../app/utils"; -import { - newProvidersEvent, - type NewProviderInfo, - type NewProvidersEventDetail, -} from "../../app/lib/provider-events"; import { ProviderIcon } from "../design-system/provider-icon"; -import { orgOnboardingVisibilityEvent } from "./reload-coordinator"; +import { events, type InferAppEventDetails } from "@/lib/event-bus"; + +type NewProvidersEventDetail = InferAppEventDetails<"openwork-new-providers-available">; +type NewProviderInfo = NewProvidersEventDetail["providers"][number]; const SEEN_KEY = "openwork.seenProviderIds"; const PENDING_MODEL_PICKER_KEY = "openwork.pendingModelPickerProviderIds"; -/** Custom event to request the model picker to open. */ -export const openModelPickerEvent = "openwork-open-model-picker"; export const pendingModelPickerProviderIdsKey = PENDING_MODEL_PICKER_KEY; function readSeenProviderIds(): Set { @@ -77,8 +73,8 @@ export function NewProvidersToast() { }, []); useEffect(() => { - const handler = (event: Event) => { - const detail = (event as CustomEvent).detail; + return events.on("openwork-new-providers-available", (event) => { + const detail = event.detail; if (detail.providers.length === 0 && !detail.newModelCount) return; if (orgOnboardingVisible) { setPendingProviders((current) => [ @@ -88,17 +84,13 @@ export function NewProvidersToast() { return; } showProviders(detail); - }; - window.addEventListener(newProvidersEvent, handler); - return () => window.removeEventListener(newProvidersEvent, handler); + }); }, [orgOnboardingVisible, showProviders]); useEffect(() => { - const handler = (event: Event) => { - setOrgOnboardingVisible(Boolean((event as CustomEvent<{ visible?: boolean }>).detail?.visible)); - }; - window.addEventListener(orgOnboardingVisibilityEvent, handler); - return () => window.removeEventListener(orgOnboardingVisibilityEvent, handler); + return events.on("openwork-org-onboarding-visibility", (event) => { + setOrgOnboardingVisible(Boolean(event.detail.visible)); + }); }, []); useEffect(() => { @@ -122,7 +114,7 @@ export function NewProvidersToast() { JSON.stringify({ newProviderIds: ids, initialTab: "available" }), ); } catch {} - window.dispatchEvent(new CustomEvent(openModelPickerEvent, { detail: { newProviderIds: ids, initialTab: "available" } })); + events.emit("openwork-open-model-picker", { newProviderIds: ids, initialTab: "available" }); window.setTimeout(() => { try { if (window.localStorage.getItem(PENDING_MODEL_PICKER_KEY)) { diff --git a/apps/app/src/react-app/shell/reload-coordinator.tsx b/apps/app/src/react-app/shell/reload-coordinator.tsx index 859ec25df1..32ca619371 100644 --- a/apps/app/src/react-app/shell/reload-coordinator.tsx +++ b/apps/app/src/react-app/shell/reload-coordinator.tsx @@ -15,6 +15,7 @@ import { t } from "../../i18n"; import { ReloadWorkspaceToast } from "../domains/shell-feedback/reload-workspace-toast"; import { StatusToastsViewport } from "../domains/shell-feedback/status-toasts"; import { useSystemState } from "../kernel/system-state"; +import { events } from "@/lib/event-bus"; type ReloadSession = { id: string; title: string }; @@ -34,8 +35,6 @@ type ReloadCoordinatorContextValue = { registerWorkspaceReloadControls: (controls: WorkspaceReloadControls | null) => () => void; }; -export const orgOnboardingVisibilityEvent = "openwork-org-onboarding-visibility"; - const ReloadCoordinatorContext = createContext(null); export function ReloadCoordinatorProvider({ children }: { children: ReactNode }) { @@ -79,13 +78,9 @@ export function ReloadCoordinatorProvider({ children }: { children: ReactNode }) const systemState = useSystemState(systemStateOptions); useEffect(() => { - const update = (event: Event) => { - setOrgOnboardingVisible(Boolean((event as CustomEvent<{ visible?: boolean }>).detail?.visible)); - }; - window.addEventListener(orgOnboardingVisibilityEvent, update); - return () => { - window.removeEventListener(orgOnboardingVisibilityEvent, update); - }; + return events.on("openwork-org-onboarding-visibility", (event) => { + setOrgOnboardingVisible(Boolean(event.detail.visible)); + }); }, []); useEffect(() => { diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 37462a80a4..079ee1c539 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -125,9 +125,9 @@ import { useControlAction, type OpenworkControlAction } from "./control/control- import { useReactRenderWatchdog } from "./react-render-watchdog"; import { readDenSettings } from "../../app/lib/den"; -import { denSessionUpdatedEvent } from "../../app/lib/den-session-events"; +import { events } from "@/lib/event-bus"; -import { openModelPickerEvent, pendingModelPickerProviderIdsKey } from "./new-providers-toast"; +import { pendingModelPickerProviderIdsKey } from "./new-providers-toast"; import { getModelBehaviorSummary } from "../../app/lib/model-behavior"; import { filterProviderList } from "../../app/utils/providers"; import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; @@ -277,7 +277,7 @@ function describeTaskCreateError(error: unknown) { function focusPromptSoon() { if (typeof window === "undefined") return; - const focus = () => window.dispatchEvent(new Event("openwork:focusPrompt")); + const focus = () => events.emit("openwork:focusPrompt"); [0, 80, 240, 600].forEach((delay) => window.setTimeout(focus, delay)); } @@ -594,8 +594,7 @@ export function SessionRoute() { const [denSessionVersion, setDenSessionVersion] = useState(0); useEffect(() => { const handler = () => setDenSessionVersion((v) => v + 1); - window.addEventListener(denSessionUpdatedEvent, handler); - return () => window.removeEventListener(denSessionUpdatedEvent, handler); + return events.on("openwork-den-session-updated", handler); }, []); // Provider IDs that were just added — used to highlight them as // "Recently added" in the model picker even after they've been @@ -603,19 +602,17 @@ export function SessionRoute() { const [recentProviderIds, setRecentProviderIds] = useState>(new Set()); // Open model picker when the global toast's "Pick a new default?" is clicked useEffect(() => { - const handler = (event: Event) => { + return events.on("openwork-open-model-picker", (event) => { try { window.localStorage.removeItem(pendingModelPickerProviderIdsKey); } catch {} - const detail = (event as CustomEvent<{ newProviderIds?: string[]; initialTab?: "default" | "available" }>).detail; + const detail = event.detail; const ids = detail?.newProviderIds; if (ids && ids.length > 0) { setRecentProviderIds(new Set(ids)); } setModelPickerOpen(true); - }; - window.addEventListener(openModelPickerEvent, handler); - return () => window.removeEventListener(openModelPickerEvent, handler); + }); }, []); useEffect(() => { @@ -1078,7 +1075,7 @@ export function SessionRoute() { await refreshProviderListQueries(getReactQueryClient()); setEngineReloadVersion((v) => v + 1); try { - window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); + events.emit("openwork-server-settings-changed"); } catch { // ignore browser event dispatch failures } @@ -1239,7 +1236,7 @@ export function SessionRoute() { refreshInFlightRef.current = false; void refreshRouteState(); }; - window.addEventListener("openwork-server-settings-changed", handleSettingsChange); + const stopSettingsUpdates = events.on("openwork-server-settings-changed", handleSettingsChange); // Also retry on visibility flip independently — even when nobody else // dispatches the settings event. @@ -1259,7 +1256,7 @@ export function SessionRoute() { window.clearTimeout(startupRetryTimerRef.current); startupRetryTimerRef.current = null; } - window.removeEventListener("openwork-server-settings-changed", handleSettingsChange); + stopSettingsUpdates(); if (typeof document !== "undefined") { document.removeEventListener("visibilitychange", handleVisibility); } @@ -1817,7 +1814,7 @@ export function SessionRoute() { setProviders(all); setProviderConnectedIds(connected); // New-provider detection is handled globally by the provider auth - // store's applyProviderListState, which fires dispatchNewProviders. + // store's applyProviderListState, which emits openwork-new-providers-available. }; void (async () => { @@ -1922,7 +1919,7 @@ export function SessionRoute() { // Flag models from recently-added providers so they appear in // the "Recently added" section at the top of the picker. // Two sources: (1) providers not yet in the localStorage seen-set, - // (2) providers passed via the openModelPickerEvent from the toast. + // (2) providers passed via the model picker event from the toast. let seenIds: Set; try { const raw = window.localStorage.getItem("openwork.seenProviderIds"); @@ -2833,7 +2830,7 @@ export function SessionRoute() { workspaceId={selectedWorkspaceId} onClose={() => { try { - window.dispatchEvent(new CustomEvent("openwork-close-right-pane")); + events.emit("openwork-close-right-pane"); } catch { // ignore } @@ -3076,14 +3073,14 @@ export function SessionRoute() { accessibleTargets={paletteAccessibleTargets} onOpenAccessibleTarget={(target) => { try { - window.dispatchEvent(new CustomEvent("openwork-open-accessible-target", { detail: target })); + events.emit("openwork-open-accessible-target", target); } catch { // ignore event dispatch failures } }} onHideAccessibleTarget={(target) => { try { - window.dispatchEvent(new CustomEvent("openwork-hide-accessible-target", { detail: target })); + events.emit("openwork-hide-accessible-target", target); } catch { // ignore event dispatch failures } diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 2c4224a908..da6396735d 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -130,7 +130,8 @@ import { readActiveWorkspaceId, writeActiveWorkspaceId } from "./session-memory" import { workspaceSessionRoute, workspaceSettingsRoute } from "./workspace-routes"; import { getReactQueryClient } from "../infra/query-client"; import { ensureProviderListQuery, getConnectedProviderItems, refreshProviderListQueries } from "../domains/connections/provider-list-query"; -import { openModelPickerEvent, pendingModelPickerProviderIdsKey } from "./new-providers-toast"; +import { pendingModelPickerProviderIdsKey } from "./new-providers-toast"; +import { events } from "@/lib/event-bus"; import { OPENAI_IMAGE_EXTENSION_ID, OPENAI_IMAGE_MODEL, @@ -647,7 +648,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { await refreshProviderListQueries(getReactQueryClient()); try { - window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); + events.emit("openwork-server-settings-changed"); } catch { // ignore browser event dispatch failures } @@ -1120,7 +1121,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { } await refreshProviderListQueries(getReactQueryClient()); try { - window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); + events.emit("openwork-server-settings-changed"); } catch { // ignore browser event dispatch failures } @@ -1156,8 +1157,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { window.localStorage.removeItem(pendingModelPickerProviderIdsKey); } catch {} }; - window.addEventListener(openModelPickerEvent, handler); - return () => window.removeEventListener(openModelPickerEvent, handler); + return events.on("openwork-open-model-picker", handler); }, []); useEffect(() => { @@ -1500,9 +1500,9 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { const handleSettingsChange = () => { void refreshRouteState(); }; - window.addEventListener("openwork-server-settings-changed", handleSettingsChange); + const stopSettingsUpdates = events.on("openwork-server-settings-changed", handleSettingsChange); return () => { - window.removeEventListener("openwork-server-settings-changed", handleSettingsChange); + stopSettingsUpdates(); }; }, [refreshRouteState]); diff --git a/apps/app/src/react-app/shell/welcome-route.tsx b/apps/app/src/react-app/shell/welcome-route.tsx index 0382ee52b4..0dc75a0ebb 100644 --- a/apps/app/src/react-app/shell/welcome-route.tsx +++ b/apps/app/src/react-app/shell/welcome-route.tsx @@ -23,6 +23,7 @@ import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient } from "../.. import { buildDenAuthUrl, readDenSettings } from "../../app/lib/den"; import { writeActiveWorkspaceId, writeLastSessionFor } from "./session-memory"; import { workspaceSessionRoute } from "./workspace-routes"; +import { events } from "@/lib/event-bus"; function folderNameFromPath(path: string) { const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); @@ -32,7 +33,7 @@ function folderNameFromPath(path: string) { function focusPromptSoon() { if (typeof window === "undefined") return; - const focus = () => window.dispatchEvent(new Event("openwork:focusPrompt")); + const focus = () => events.emit("openwork:focusPrompt"); [0, 80, 240, 600].forEach((delay) => window.setTimeout(focus, delay)); }