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):
EnvironmentApplicationOptionsgainsstorage?: DeviceStorage | nullandsubscribePageExit?.EnvironmentApplicationProvidersprovides them;useDeviceStorage()andusePageExit()read them.- If a host passes nothing, both fall back to
lib/browser-device.ts: guardedglobalThis.localStorage, and capture-phasepagehide+freezelisteners. That is exactly what the code did before. Web'sapp-shell.tsxpasses neither option, so web is unchanged.storage: nullkeeps nothing. - Seamed:
composerDraftsread/write,composerDraftStore(createLocalComposerDraftStore(storage)),favoriteModelsread/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), andenvironment-application(folded sidebar rows;collapsedWorkspaceStoragestays and defaults to the device storage). Each pure function takes an optional trailingstorageargument that defaults to the browser's, so existing callers and tests are untouched. environment-sidebar-draftssubscribes tousePageExit()instead of addingwindow/documentlisteners. 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
localStorageitself (some private modes throw on the property),readComposerDraftsandreadFavoriteModelsnow return empty instead of throwing out oftypeof localStorage, andwriteComposerDrafts/writeFavoriteModelsreturn quietly instead of throwing (a full-quota error still throws fromwriteFavoriteModels, 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
| Module | Why |
|---|---|
providers/theme-provider | Owns the DOM: root classes, matchMedia, localStorage for appearance/font. theme-families (the data) is in. |
providers/fluid-provider | motion wrapper. |
lib/notice-anchors | Positions notices against DOM elements. |
lib/shiki | Shiki highlighter. |
lib/utils | cn (clsx + tailwind-merge): web class names. |
lib/typography, lib/fonts | Web typography tokens (Tailwind classes, Fontsource faces). |
components/chat/chatComposerStyles, userMessageStyles | Tailwind class strings for the web composer. |
components/command/pickerRegistry | Holds picker focus by listening to pointerdown / pointerup / focusin on document. |
components/ui/usePortaledMenu | DOM portal positioning. |
components/parts/ToolRegistry | Maps tools to Phosphor icons. |
components/parts/toolPresenter | Only 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.mjswalkssrc/logic.tson each lint run. It parses with@typescript-eslint/parserand follows static, type-only,export … from,import()andimport('x').Timports and self-references.eslint.config.mjsapplies a stricter block to exactly those files: no CSS,react-dom,motion,shiki/@shikijs,pdfjs-dist,@radix-ui,@base-uior@phosphor-icons; a package allowlist (a new package joins it on purpose); no browser globals (the list includesCSS,Worker,customElements, element/event constructors and window geometry), withglobalThisreadable only asglobalThis.name/globalThis['name'](no destructuring, aliasing, passing on or computed keys); and no dynamicimport().lib/browser-device.tsis the one file exempt from the globals rule. The walker uses the parser ESLint already loads rather thantypescript, 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/logicthrough Node's resolver and expects it to besrc/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 navigatorprobe; - 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.
- resolves
- 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 inbeforeAll, which fixes the long-standing cold-start timeout on the first case. - Proved once by hand. Adding
react-dom,globals.cssandwindow.innerWidthtolib/relative-time.tsmade lint fail with 4 errors. Atypeof documentread made the node test fail, pointing atrelative-time.ts:38. Both probes were reverted.
Verification (Node 24.14.1)
- app-core:
tsc --noEmitclean;eslint src tests .storybook vite.config.ts0 errors (3 warnings, all already on main); vitest 48 files / 512 tests pass; Storybook builds. - web: both
tscprojects clean; lint clean; vitest 29 files / 352 tests pass. - desktop:
tsconfig.node+tsconfig.webclean; 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 followssubscribePageExit, 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, aliasingglobalThis,CSSandWorker. Fixed by theglobalThisrule and the longer globals list, each with a boundary case. - Not fixed here (open item for the mobile Metro config):
@agentpack/view,@agentpack/contractand@openmanager/protocolexport TypeScript source that uses NodeNext-style./x.jsrelative specifiers. Metro's default resolver does not map those to.ts, so the phone will need aresolver.resolveRequestinapps/mobile/metro.config.jsthat retries a relative.jsas the.tssource. Dropping the suffixes in those packages instead would break their NodeNext builds (protocol builds the server's./nodedist), and mobile code is out of scope for this change.
🤖 Generated with Claude Code
Activity
10🚀 Deploying Preview to Cloudflare 🚀
Preview Deployments by commit
| Status | Deployment URL | Commit | Updated (UTC) | See this deployment's details |
|---|---|---|---|---|
View logs ↗ | e3e4cc5 | 2026-10-20T21:28:29.264Z | View logs ↗ | |
View logs ↗ | bed509e | 2026-10-20T17:08:13.656Z | View logs ↗ |
[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/logicso React Native can import shared providers, stores, and helpers without the web UI.Diagram
Reviews (2) · Last reviewed commit: "Resubscribe page exit on a new host sour..." · Reviewed by Greptile