mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
* feat(shortcuts): make browser-reserved accelerators recordable on desktop Cmd/Ctrl+P (and L/T/N/D/U) were rejected by the shortcut recorder on every platform because a browser tab cannot reliably own them. The Electron renderer receives these as plain keydowns — neither Electron's default menu nor the desktop shell binds any of them — so the reservation now only applies to the web runtime. Adds a ShortcutRuntime dimension (configured by CoreProvider from the client identity, with a preload-global fallback that is already correct at module-eval time so store hydration sanitizes with the right runtime). App-owned accelerators (W/R/Q, editing keys, zoom row) stay reserved everywhere. Closes MUL-4457 Co-authored-by: multica-agent <github@multica.ai> * fix(shortcuts): limit desktop unlock to bare primary browser accelerators Review follow-up (MUL-4457): the desktop skip matched primary+key with any extra modifiers, which would also unreserve OS-owned combos such as Option+Cmd+D (macOS Dock toggle) and Ctrl+Alt+T (Linux terminal). Only the bare primary chord is now recordable on desktop; every extra-modifier variant keeps the historical reservation on both runtimes. Adds regression tests for the OS combos. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
150 lines
4.5 KiB
TypeScript
150 lines
4.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo } from "react";
|
|
import { ApiClient } from "../api/client";
|
|
import { installFreezeWatchdog } from "../diagnostics/freeze-watchdog";
|
|
import { setApiInstance, setSchemaLogger } from "../api";
|
|
import { createAuthStore, registerAuthStore } from "../auth";
|
|
import { createChatStore, registerChatStore } from "../chat";
|
|
import {
|
|
I18nProvider,
|
|
LocaleAdapterProvider,
|
|
UserLocaleSync,
|
|
} from "../i18n/react";
|
|
import { WSProvider } from "../realtime";
|
|
import { QueryProvider } from "../provider";
|
|
import { createLogger } from "../logger";
|
|
import { defaultStorage } from "./storage";
|
|
import { AuthInitializer } from "./auth-initializer";
|
|
import type { CoreProviderProps, ClientIdentity } from "./types";
|
|
import type { StorageAdapter } from "../types/storage";
|
|
import {
|
|
configureShortcutPlatform,
|
|
configureShortcutRuntime,
|
|
} from "../shortcuts/platform";
|
|
|
|
// Module-level singletons — created once at first render, never recreated.
|
|
// Vite HMR preserves module-level state, so these survive hot reloads.
|
|
let initialized = false;
|
|
let authStore: ReturnType<typeof createAuthStore>;
|
|
let chatStore: ReturnType<typeof createChatStore>;
|
|
function initCore(
|
|
apiBaseUrl: string,
|
|
storage: StorageAdapter,
|
|
onLogin?: () => void,
|
|
onLogout?: () => void,
|
|
cookieAuth?: boolean,
|
|
identity?: ClientIdentity,
|
|
) {
|
|
if (initialized) return;
|
|
|
|
configureShortcutPlatform(
|
|
identity?.os === "macos" ||
|
|
identity?.os === "windows" ||
|
|
identity?.os === "linux" ||
|
|
identity?.os === "unknown"
|
|
? identity.os
|
|
: null,
|
|
);
|
|
// Authoritative override; before this runs (module-eval store hydration)
|
|
// detectShortcutRuntime() reads the preload globals and already agrees.
|
|
configureShortcutRuntime(
|
|
identity?.platform === "desktop" ? "desktop" : null,
|
|
);
|
|
|
|
const api = new ApiClient(apiBaseUrl, {
|
|
logger: createLogger("api"),
|
|
onUnauthorized: () => {
|
|
storage.removeItem("multica_token");
|
|
},
|
|
identity,
|
|
});
|
|
setApiInstance(api);
|
|
setSchemaLogger(createLogger("api-schema"));
|
|
|
|
// In token mode, hydrate token from storage.
|
|
if (!cookieAuth) {
|
|
const token = storage.getItem("multica_token");
|
|
if (token) api.setToken(token);
|
|
}
|
|
// Workspace identity is URL-driven: the [workspaceSlug] layout resolves
|
|
// the slug and calls setCurrentWorkspace(slug, wsId) on mount. The api
|
|
// client reads the slug from that singleton for the X-Workspace-Slug
|
|
// header. No boot-time hydration from storage is required.
|
|
|
|
authStore = createAuthStore({ api, storage, onLogin, onLogout, cookieAuth });
|
|
registerAuthStore(authStore);
|
|
|
|
chatStore = createChatStore({ storage });
|
|
registerChatStore(chatStore);
|
|
|
|
initialized = true;
|
|
}
|
|
|
|
export function CoreProvider({
|
|
children,
|
|
apiBaseUrl = "",
|
|
wsUrl = "ws://localhost:8080/ws",
|
|
storage = defaultStorage,
|
|
cookieAuth,
|
|
onLogin,
|
|
onLogout,
|
|
identity,
|
|
locale,
|
|
resources,
|
|
localeAdapter,
|
|
}: CoreProviderProps) {
|
|
// Initialize singletons on first render only. Dependencies are read-once:
|
|
// apiBaseUrl, storage, and callbacks are set at app boot and never change at runtime.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth, identity), []);
|
|
|
|
// Client-only freeze watchdog — shared by web and desktop. No-op on the
|
|
// server and idempotent, so mounting it here covers both apps in one place.
|
|
useEffect(() => {
|
|
installFreezeWatchdog();
|
|
}, []);
|
|
|
|
// I18nProvider wraps everything else: server and client must use the same
|
|
// (locale, resources) to avoid hydration mismatch. Language switching goes
|
|
// through window.location.reload(), never client-side changeLanguage.
|
|
const tree = (
|
|
<QueryProvider>
|
|
<AuthInitializer
|
|
onLogin={onLogin}
|
|
onLogout={onLogout}
|
|
storage={storage}
|
|
cookieAuth={cookieAuth}
|
|
identity={identity}
|
|
>
|
|
<WSProvider
|
|
wsUrl={wsUrl}
|
|
authStore={authStore}
|
|
storage={storage}
|
|
cookieAuth={cookieAuth}
|
|
identity={identity}
|
|
>
|
|
{children}
|
|
</WSProvider>
|
|
</AuthInitializer>
|
|
</QueryProvider>
|
|
);
|
|
|
|
// UserLocaleSync requires a LocaleAdapter to persist; only mount it when
|
|
// the host app provides one (web layout + desktop App both do).
|
|
const withAdapter = localeAdapter ? (
|
|
<LocaleAdapterProvider adapter={localeAdapter}>
|
|
<UserLocaleSync />
|
|
{tree}
|
|
</LocaleAdapterProvider>
|
|
) : (
|
|
tree
|
|
);
|
|
|
|
return (
|
|
<I18nProvider locale={locale} resources={resources}>
|
|
{withAdapter}
|
|
</I18nProvider>
|
|
);
|
|
}
|