#225

Give app-core a DOM-free logic entry for React Native

Opencalledforthwants to merge intofrom

What this does

Adds @openmanager/app-core/logic, an entry React Native (Metro) can load. It exports app-core's providers, stores and pure helpers without the web UI, globals.css, or the packages Metro cannot load (Radix, base-ui, motion, Shiki, pdf.js, Phosphor, react-dom). Web and desktop keep importing . and the per-module subpaths, and behave exactly as before.

package.json exports ./logic -> ./src/logic.ts, the same way the root and the other subpaths are exported. Web, desktop and Storybook resolve app-core only through exports (no aliases anywhere), so nothing else needed changing.

Storage and page-exit seams

The logic layer read localStorage directly in a few places, and the sidebar's draft-discard flush listened to window/document. These now go through a DeviceHost context (providers/device-host.ts):

  • EnvironmentApplicationOptions gains storage?: DeviceStorage | null and subscribePageExit?. EnvironmentApplicationProviders provides them; useDeviceStorage() and usePageExit() read them.
  • If a host passes nothing, both fall back to lib/browser-device.ts: guarded globalThis.localStorage, and capture-phase pagehide + freeze listeners. That is exactly what the code did before. Web's app-shell.tsx passes neither option, so web is unchanged. storage: null keeps nothing.
  • Seamed: composerDrafts read/write, composerDraftStore (createLocalComposerDraftStore(storage)), favoriteModels read/write (the model picker now reads favourites through the device host too), draft-pages (rememberSentDraft / sentDraftSession / forgetSentDraft), environment-drafts (the one-time local draft import), and environment-application (folded sidebar rows; collapsedWorkspaceStorage stays and defaults to the device storage). Each pure function takes an optional trailing storage argument that defaults to the browser's, so existing callers and tests are untouched.
  • environment-sidebar-drafts subscribes to usePageExit() instead of adding window/document listeners. It reads the subscriber through a ref, so a host passing a new function does not count as leaving the page and let the pending discard go.
  • One deliberate difference: when the browser refuses access to localStorage itself (some private modes throw on the property), readComposerDrafts and readFavoriteModels now return empty instead of throwing out of typeof localStorage, and writeComposerDrafts / writeFavoriteModels return quietly instead of throwing (a full-quota error still throws from writeFavoriteModels, as on main). Every other path is the same call on the same object.

The client keeps only what one device remembers for itself (folded rows, where a sent draft's address leads, pre-environment composer drafts, favourite models). Nothing server-owned moved.

To keep the logic graph off .tsx components, two types moved out of components. ProviderModelOption / ProviderModelGroup / PickerProviderId now live in components/chat/providerModelGroups.ts, and ProviderIconId in components/providers/providerIconId.ts. ProviderModelPicker.tsx and ProviderIcon.tsx re-export them, so no import changed anywhere.

Exported by @openmanager/app-core/logic

Providers: active-thread-provider, composer-provider, device-host, draft-pages, draft-provider, environment-application, environment-client, environment-composer, environment-drafts, environment-sidebar-drafts, permission-provider, plan-provider, platform-provider, question-provider, session-provider, sidebar-provider, view-actions.

lib: artifact-preview, attachments, environment-thread, provider-health-view, relative-time, stream-continuity, streaming-messages-store, theme-families, workspace-availability.

Component logic: chat/composerDrafts, chat/composerDraftStore, chat/favoriteModels, chat/modelConfig, chat/modelLabel, chat/providerModelGroups, chat/slashCommands, chat/timelinePositions, parts/turn-work-group, questions/askedQuestion, questions/questionFlow, questions/useQuestionFlow, sidebar/sidebar-sessions, workspace/folder-browser.

Also in the graph but not re-exported: lib/browser-device (the browser defaults) and components/providers/providerIconId (a type).

The graph's only outside imports are react, @agentpack/contract, @agentpack/view, @openmanager/protocol, @openmanager/shared/contracts/*, @openmanager/shared/lib/remote-stream-parts, @openmanager/environment-client, and @tanstack/react-virtual (type-only, VirtualItem).

Things a native host must supply at runtime (they are never touched at import time): crypto.randomUUID (draft and session ids in environment-application and environment-drafts) and URL.createObjectURL (lib/artifact-preview, only when an artifact is previewed).

Deliberately left out

ModuleWhy
providers/theme-providerOwns the DOM: root classes, matchMedia, localStorage for appearance/font. theme-families (the data) is in.
providers/fluid-providermotion wrapper.
lib/notice-anchorsPositions notices against DOM elements.
lib/shikiShiki highlighter.
lib/utilscn (clsx + tailwind-merge): web class names.
lib/typography, lib/fontsWeb typography tokens (Tailwind classes, Fontsource faces).
components/chat/chatComposerStyles, userMessageStylesTailwind class strings for the web composer.
components/command/pickerRegistryHolds picker focus by listening to pointerdown / pointerup / focusin on document.
components/ui/usePortaledMenuDOM portal positioning.
components/parts/ToolRegistryMaps tools to Phosphor icons.
components/parts/toolPresenterOnly re-exports @openmanager/shared/lib/tool-presenter, which a native host imports directly.
components/fluid/** (hooks/*, lib/*, sidebar-app/nav-data)The Fluid UI kit: hover/keyboard DOM hooks, motion springs, surface classes.

No module the issue listed had to be dropped for lack of a clean seam.

Guards

  • ESLint, scoped to the import graph. packages/app-core/scripts/logic-graph.mjs walks src/logic.ts on each lint run. It parses with @typescript-eslint/parser and follows static, type-only, export … from, import() and import('x').T imports and self-references. eslint.config.mjs applies a stricter block to exactly those files: no CSS, react-dom, motion, shiki/@shikijs, pdfjs-dist, @radix-ui, @base-ui or @phosphor-icons; a package allowlist (a new package joins it on purpose); no browser globals (the list includes CSS, Worker, customElements, element/event constructors and window geometry), with globalThis readable only as globalThis.name / globalThis['name'] (no destructuring, aliasing, passing on or computed keys); and no dynamic import(). lib/browser-device.ts is the one file exempt from the globals rule. The walker uses the parser ESLint already loads rather than typescript, which is not hoisted to the root and would be missing in the server job's filtered install.
  • Node test. tests/logic-entry.test.ts (@vitest-environment node) does four things:
    • resolves @openmanager/app-core/logic through Node's resolver and expects it to be src/logic.ts;
    • replaces every listed browser global with a recording getter, imports the entry, and fails on any touch, with one tolerated exception: zod's guarded typeof navigator probe;
    • runs the storage seams on an injected storage with the traps still armed;
    • walks the static graph for CSS and forbidden specifiers, because vitest stubs CSS at runtime.
  • Lint cases in tests/boundary.test.ts. They prove the scoped rules reject DOM imports and globals in a graph file and still allow them in a UI file. They also warm ESLint's config once in beforeAll, which fixes the long-standing cold-start timeout on the first case.
  • Proved once by hand. Adding react-dom, globals.css and window.innerWidth to lib/relative-time.ts made lint fail with 4 errors. A typeof document read made the node test fail, pointing at relative-time.ts:38. Both probes were reverted.

Verification (Node 24.14.1)

  • app-core: tsc --noEmit clean; eslint src tests .storybook vite.config.ts 0 errors (3 warnings, all already on main); vitest 48 files / 512 tests pass; Storybook builds.
  • web: both tsc projects clean; lint clean; vitest 29 files / 352 tests pass.
  • desktop: tsconfig.node + tsconfig.web clean; lint 0 errors (42 warnings, none in files this touches); vitest 45 files / 660 tests pass.

Review notes

An independent review (gpt-6.1-sol) and Greptile found:

  • Page-exit subscription went stale when a host handed over a new subscribePageExit (both reviewers). Fixed: the subscription follows subscribePageExit, and leaving the environment is a separate flush, so swapping the source does not commit a pending discard. A regression test fails on the old code.
  • Lint escapes such as const { document } = globalThis, aliasing globalThis, CSS and Worker. Fixed by the globalThis rule and the longer globals list, each with a boundary case.
  • Not fixed here (open item for the mobile Metro config): @agentpack/view, @agentpack/contract and @openmanager/protocol export TypeScript source that uses NodeNext-style ./x.js relative specifiers. Metro's default resolver does not map those to .ts, so the phone will need a resolver.resolveRequest in apps/mobile/metro.config.js that retries a relative .js as the .ts source. Dropping the suffixes in those packages instead would break their NodeNext builds (protocol builds the server's ./node dist), and mobile code is out of scope for this change.

🤖 Generated with Claude Code

Activity

10
greptile-apps[bot]10d ago


[Medium impact] The PR appears safe to merge; the previous exit-subscription finding is fixed, and no new actionable issues were found.

Summary

Adds @openmanager/app-core/logic so React Native can import shared providers, stores, and helpers without the web UI.

  • React Native can load app-core's providers and helpers without web UI imports.
  • App-core lets hosts provide device storage and page-exit signals.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Native[React Native host] --> Logic["@openmanager/app-core/logic"]
  Browser[Web and desktop hosts] --> UI[Existing UI exports]
  Logic --> Shared[Shared providers and stores]
  UI --> Shared
  Shared --> Device[DeviceHost]
  Device --> Injected[Host storage and exit signals]
  Device --> Defaults[Guarded browser defaults]

Reviews (2) · Last reviewed commit: "Resubscribe page exit on a new host sour..." · Reviewed by Greptile

greptile-apps[bot]reviewed10d ago
greptile-apps[bot]reviewed10d ago
cloudflare-workers-and-pages[bot]10d ago

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

StatusDeployment URLCommitUpdated (UTC)See this deployment's details
  • Build: Failed ❌

View logs ↗
e3e4cc52026-10-20T21:28:29.264ZView logs ↗
  • Build: Failed ❌

View logs ↗
bed509e2026-10-20T17:08:13.656ZView logs ↗
calledforthreviewed10d ago
calledforthreviewed10d ago