basalt-ui 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -23,6 +23,7 @@ Toolchain: Bun runtime, oxlint + oxfmt, conventional commits (empty scope), `mas
23
23
  | `basalt-ui/data/table` | mantine-coupled | BasaltDataTable: a sortable data table over TanStack Table, rendered with Mantine (Mantine-rendered) |
24
24
  | `basalt-ui/data/virtual` | mantine-coupled | BasaltVirtualList: a windowed virtual list over TanStack Virtual, rendered with Mantine (Mantine-rendered) |
25
25
  | `basalt-ui/agent` | headless | Headless streaming-chat layer: useAgentStream, aiSdkTransport (recommended default) + edenTransport, PartList, plus the multi-thread createThreadsStore + useAgentThreadRuns + outcome-resolver seam (Mantine-free) |
26
+ | `basalt-ui/agent-chat` | mantine-coupled | Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed, ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript, threadPartRenderers (Mantine-coupled). remend and motion are required, not optional — ThreadTranscript imports content/markdown.tsx (remend eagerly), and ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without both installed even though peerDependenciesMeta marks them optional (npm has no per-subpath optionality). |
26
27
  | `basalt-ui/content` | mantine-coupled | Prose (article/chat typography), CodeBlock (shiki, optional peer), Callout, TableOfContents, ReadingProgress, Markdown (react-markdown + remark-gfm, optional peers, streaming-aware), MermaidDiagram (beautiful-mermaid, optional peer), mdxComponents/createMdxComponents, ArticleLayout (docs-page frame), ArticleCard/ArticleGrid (overview cards), Article model (sortArticles/filterArticles/formatArticleDate), ArticleFilterBar (category/tags filter UI), toArticleActions (Spotlight projector, @mantine/spotlight type-only), GuideLink/GuideDrawer (contextual-help drawer) — the content/prose surface |
27
28
  | `basalt-ui/state` | headless | createPersistedState (versioned localStorage) + useOnlineStatus — Mantine-free state primitives |
28
29
  | `basalt-ui/connectivity` | mantine-coupled | ConnectivityProvider (aggregates browser online/offline, React Query onlineManager, SSE, and health-check pings into one status), useConnectivity, and ConnectivityIndicator — auto-mounted by BasaltProvider |
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ # [1.10.0](https://github.com/jkrumm/basalt-ui/compare/v1.9.0...v1.10.0) (2026-08-02)
2
+
3
+
4
+ ### Features
5
+
6
+ * open ./agent-chat as its own door, and put the agent layer under test ([2005aa6](https://github.com/jkrumm/basalt-ui/commit/2005aa6db90d02e9310188873027ecf8b93027ff))
7
+ * prove the agent-chat subpath and the streaming wedge in the playground ([caff9c4](https://github.com/jkrumm/basalt-ui/commit/caff9c4f4a3d92259a1e8a88348474c90dc4c18a))
8
+
1
9
  # [1.9.0](https://github.com/jkrumm/basalt-ui/compare/v1.8.0...v1.9.0) (2026-08-02)
2
10
 
3
11
 
package/README.md CHANGED
@@ -195,34 +195,55 @@ key in your `package.json`:
195
195
  scanned files. Other `"basalt"` config keys (`exempt`,
196
196
  `spacingSteps`, `forbiddenAccents`, …) are documented on the `BasaltConfig` type.
197
197
 
198
+ `doctor`'s `ai-major-parity` hard check fails a monorepo where workspace packages declare different
199
+ `ai` package majors — unless the split is intentional and written down. A producer pinned to an
200
+ older major with a client on a newer one, neutralized by a transform, is a locked decision, not a
201
+ defect:
202
+
203
+ ```json
204
+ // package.json (repo root)
205
+ {
206
+ "basalt": {
207
+ "aiMajorSkewReason": "apps/api streams on ai@5, apps/dashboard parses on ai@7 — the one enum value that differs is neutralized by a producer-side TransformStream in apps/api/src/stream-transform.ts"
208
+ }
209
+ }
210
+ ```
211
+
212
+ The value **is** the reason — omitting the key, or setting it to `true`/`""`, does not weaken the
213
+ check; `doctor` still hard-fails a detected skew exactly as if `aiMajorSkewReason` were absent. Once
214
+ the reason is written, `doctor` passes and echoes both the detected skew and the reason back. If the
215
+ majors later come back into agreement, `doctor` warns that the declaration is stale so it gets
216
+ deleted rather than silently surviving into the next real skew.
217
+
198
218
  ---
199
219
 
200
220
  ## Subpath exports
201
221
 
202
- | Subpath | Mantine? | Purpose |
203
- | ------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
204
- | `.` | coupled | `BasaltProvider`, `createBasaltTheme` / `baseTheme` / `cssVariablesResolver`, `BasaltShell` + sidebar / mobile-nav / breadcrumbs / page-header, `NavCountBadge`, `SidebarAccount` + the provider-agnostic account contract (`BasaltAccountProps`/`State`/`Actions`), `ThreadWorkspace` + thread-chat components, shell types |
205
- | `./charts` | **free** | visx chart primitives, sparklines, hooks, and token re-exports |
206
- | `./tokens` | **free** | `VX` token refs, `buildPaletteCss`, `defineSeries`, `seriesTokens`, `groupTokens`, `alpha`, `ColorPair` / `SeriesMap` types |
207
- | `./theme-lab` | coupled | `ThemeLabControls`, `applyOverrides`, `loadOverrides`, `COLOR_GROUPS` for live theme inspection |
208
- | `./vite` | — | `basaltViteConfig(opts)` — Vite preset for basalt-ui consumer apps; `basaltAppPlugin(opts)` — PWA head, manifest, and icon metadata derived from the token palette |
209
- | `./guard` | **free** | `checkSource`, `GUARD_RULES`, `Finding` types — the headless theme-guard core |
210
- | `./query` | **free** | `createBasaltQueryClient`, transport-agnostic `unwrap`, lazy `BasaltQueryDevtools` |
211
- | `./router-tanstack` | **free** | TanStack Router bridge: `useBasaltNav` (active route) + `useRouterBreadcrumbs` |
212
- | `./forms` | coupled | Mantine form adapter: `useBasaltForm`, `field`, `FormErrorSummary`, `useFormDraft` (Standard Schema) |
213
- | `./notifications` | coupled | Mantine notifications: `notify` helpers, typed registry, persisted history, `NotificationBell` |
214
- | `./commands` | coupled | Typed command bus + overlay controller, `toSpotlightActions`, `ShortcutsHelp`, `BasaltOverlays` |
215
- | `./data` | coupled | Convenience barrel pulling both peer groups: `BasaltDataTable`, `BasaltVirtualList` (Mantine-rendered) — prefer `./data/table` / `./data/virtual` for per-feature opt-in |
216
- | `./data/table` | coupled | `BasaltDataTable` — sortable data table over TanStack Table, rendered with Mantine |
217
- | `./data/virtual` | coupled | `BasaltVirtualList` — windowed virtual list over TanStack Virtual, rendered with Mantine |
218
- | `./agent` | **free** | Headless streaming layer (`useAgentStream`, `PartList`) + multi-thread `createThreadsStore` / `useAgentThreadRuns` / outcome seam |
219
- | `./state` | **free** | `createPersistedState` (versioned localStorage) + `useOnlineStatus` — Mantine-free state primitives |
220
- | `./connectivity` | coupled | `ConnectivityProvider` (aggregates browser/React-Query/SSE/health-check status), `useConnectivity`, `ConnectivityIndicator` — auto-mounted by `BasaltProvider` |
221
- | `./content` | coupled | `Prose`, `CodeBlock`, `Callout`, `TableOfContents`, `ReadingProgress`, `Markdown`, `MermaidDiagram`, `ArticleLayout`, `ArticleCard` / `ArticleGrid`, `GuideLink` / `GuideDrawer`, `mdxComponents` |
222
- | `./styles.css` | — | `@layer basalt` base styles, iOS input safety net, font stack |
223
- | `./tokens.css` | **free** | Prebuilt `--vx-*` stylesheet — the default `buildPaletteCss()` output as a plain file, for a consumer with no bundler, React or Mantine; `basalt-ui tokens:css` re-emits it with a custom scheme selector |
224
- | `./configs/*` | — | Raw toolchain presets — oxlint, oxfmt, tsconfig (base/react-app/node), lefthook |
225
- | `./llms.txt` | — | Machine-readable surface map — one entry per published subpath with import specifier, description, layer, and optional peers |
222
+ | Subpath | Mantine? | Purpose |
223
+ | ------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
224
+ | `.` | coupled | `BasaltProvider`, `createBasaltTheme` / `baseTheme` / `cssVariablesResolver`, `BasaltShell` + sidebar / mobile-nav / breadcrumbs / page-header, `NavCountBadge`, `SidebarAccount` + the provider-agnostic account contract (`BasaltAccountProps`/`State`/`Actions`), `ThreadWorkspace` + thread-chat components, shell types — the root entry still re-exports the thread-chat components; `./agent-chat` takes the same components standalone, without `BasaltProvider`, the shell, the dashboard composites, or `./connectivity` |
225
+ | `./charts` | **free** | visx chart primitives, sparklines, hooks, and token re-exports |
226
+ | `./tokens` | **free** | `VX` token refs, `buildPaletteCss`, `defineSeries`, `seriesTokens`, `groupTokens`, `alpha`, `ColorPair` / `SeriesMap` types |
227
+ | `./theme-lab` | coupled | `ThemeLabControls`, `applyOverrides`, `loadOverrides`, `COLOR_GROUPS` for live theme inspection |
228
+ | `./vite` | — | `basaltViteConfig(opts)` — Vite preset for basalt-ui consumer apps; `basaltAppPlugin(opts)` — PWA head, manifest, and icon metadata derived from the token palette |
229
+ | `./guard` | **free** | `checkSource`, `GUARD_RULES`, `Finding` types — the headless theme-guard core |
230
+ | `./query` | **free** | `createBasaltQueryClient`, transport-agnostic `unwrap`, lazy `BasaltQueryDevtools` |
231
+ | `./router-tanstack` | **free** | TanStack Router bridge: `useBasaltNav` (active route) + `useRouterBreadcrumbs` |
232
+ | `./forms` | coupled | Mantine form adapter: `useBasaltForm`, `field`, `FormErrorSummary`, `useFormDraft` (Standard Schema) |
233
+ | `./notifications` | coupled | Mantine notifications: `notify` helpers, typed registry, persisted history, `NotificationBell` |
234
+ | `./commands` | coupled | Typed command bus + overlay controller, `toSpotlightActions`, `ShortcutsHelp`, `BasaltOverlays` |
235
+ | `./data` | coupled | Convenience barrel pulling both peer groups: `BasaltDataTable`, `BasaltVirtualList` (Mantine-rendered) — prefer `./data/table` / `./data/virtual` for per-feature opt-in |
236
+ | `./data/table` | coupled | `BasaltDataTable` — sortable data table over TanStack Table, rendered with Mantine |
237
+ | `./data/virtual` | coupled | `BasaltVirtualList` — windowed virtual list over TanStack Virtual, rendered with Mantine |
238
+ | `./agent` | **free** | Headless streaming layer (`useAgentStream`, `PartList`) + multi-thread `createThreadsStore` / `useAgentThreadRuns` / outcome seam |
239
+ | `./agent-chat` | coupled | Mantine-styled thread-chat components over `./agent`: `ThreadWorkspace`, `ThreadFeed`, `ThreadOutcomeCard`, `ThreadDetailPanel`, `Composer`, `ThreadTranscript`, `threadPartRenderers` — **requires `remend` and `motion`**, not merely optional peers; see [Requirements](#requirements) |
240
+ | `./state` | **free** | `createPersistedState` (versioned localStorage) + `useOnlineStatus` — Mantine-free state primitives |
241
+ | `./connectivity` | coupled | `ConnectivityProvider` (aggregates browser/React-Query/SSE/health-check status), `useConnectivity`, `ConnectivityIndicator` — auto-mounted by `BasaltProvider` |
242
+ | `./content` | coupled | `Prose`, `CodeBlock`, `Callout`, `TableOfContents`, `ReadingProgress`, `Markdown`, `MermaidDiagram`, `ArticleLayout`, `ArticleCard` / `ArticleGrid`, `GuideLink` / `GuideDrawer`, `mdxComponents` |
243
+ | `./styles.css` | — | `@layer basalt` base styles, iOS input safety net, font stack |
244
+ | `./tokens.css` | **free** | Prebuilt `--vx-*` stylesheet — the default `buildPaletteCss()` output as a plain file, for a consumer with no bundler, React or Mantine; `basalt-ui tokens:css` re-emits it with a custom scheme selector |
245
+ | `./configs/*` | — | Raw toolchain presets — oxlint, oxfmt, tsconfig (base/react-app/node), lefthook |
246
+ | `./llms.txt` | — | Machine-readable surface map — one entry per published subpath with import specifier, description, layer, and optional peers |
226
247
 
227
248
  Named exports only — no default exports.
228
249
 
@@ -429,7 +450,11 @@ bun add beautiful-mermaid # MermaidDiagram / ```mermaid fences
429
450
  ````
430
451
 
431
452
  `remend` is the one peer here that is not lazy: `Markdown` imports it at the top of the module, so
432
- `basalt-ui/content` fails to resolve without it even if you never render `Markdown` yourself.
453
+ `basalt-ui/content` fails to resolve without it even if you never render `Markdown` yourself. That
454
+ eager import also reaches past this subpath: both the root `.` entry and `./agent-chat`'s
455
+ `ThreadWorkspace` / `ThreadTranscript` import `Markdown` directly (not lazily) as their text
456
+ renderer, so `remend` is listed as **required** in the [Requirements](#requirements) table too, not
457
+ only here.
433
458
  Everything else degrades gracefully when absent (markdown falls back to plain text, fences to
434
459
  plain mono, mermaid renders nothing). `Markdown` is the package's **only** markdown
435
460
  renderer — it backs long-form content and AI chat alike:
@@ -468,10 +493,11 @@ const { parts, send, status } = useAgentStream({ transport })
468
493
  ```
469
494
 
470
495
  Many short chats — the `ThreadWorkspace` composite (a distilled-outcome feed + a detail panel).
471
- The headless multi-thread layer lives in `./agent`; the Mantine chrome ships from the root entry:
496
+ The headless multi-thread layer lives in `./agent`; the Mantine chrome ships from
497
+ `basalt-ui/agent-chat` (also re-exported from the root entry):
472
498
 
473
499
  ```tsx
474
- import { ThreadWorkspace } from 'basalt-ui'
500
+ import { ThreadWorkspace } from 'basalt-ui/agent-chat'
475
501
  import { createThreadsStore, heuristicOutcome } from 'basalt-ui/agent'
476
502
 
477
503
  // Once at module scope:
@@ -541,7 +567,7 @@ bunx basalt-ui init # scaffold doctrine into a consumer repo
541
567
  bunx basalt-ui sync # three-way diff against .basalt/manifest.json after a basalt-ui upgrade
542
568
  bunx basalt-ui sync --check # CI drift gate — non-zero exit on any managed-file drift
543
569
  bunx basalt-ui check-theme # palette guard — fails on colors that bypass the central --vx-* system
544
- bunx basalt-ui doctor # check consumer repo basalt integration health (manifest + one version axis: node_modules vs manifest) + warn on missing basaltAppPlugin icon files under public/
570
+ bunx basalt-ui doctor # check consumer repo basalt integration health: manifest presence, node_modules-vs-manifest version, spacing-scale drift, and a cross-package `ai` major-version parity check (HARD failure — walks every workspace manifest, exemptable via `basalt.aiMajorSkewReason` — see above) + warn on missing basaltAppPlugin icon files under public/
545
571
  bunx basalt-ui info # human-readable surface map: subpath, layer, rule, skills, optional peers
546
572
  bunx basalt-ui info --json # same map as stable JSON (InfoOutput shape)
547
573
  ```
@@ -580,12 +606,16 @@ Wire the drift gate to catch doctrine falling behind after a basalt-ui upgrade:
580
606
 
581
607
  ## Requirements
582
608
 
583
- | Peer | Version | Notes |
584
- | ----------------------- | ------- | ---------------------------------------------------------- |
585
- | `react` / `react-dom` | `^19` | required |
586
- | `@mantine/core` | `^9.3` | required |
587
- | `@mantine/hooks` | `^9.3` | required |
588
- | `@tanstack/react-query` | — | required — `BasaltProvider` hard-requires it at build time |
609
+ | Peer | Version | Notes |
610
+ | ----------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
611
+ | `react` / `react-dom` | `^19` | required |
612
+ | `@mantine/core` | `^9.3` | required |
613
+ | `@mantine/hooks` | `^9.3` | required |
614
+ | `@tanstack/react-query` | — | required for the root `.` entry — `BasaltProvider` hard-requires it at build time; NOT required by `./agent-chat`, which doesn't touch it |
615
+ | `remend` | `1.3.0` | required for both the root `.` entry and `./agent-chat` — both export `ThreadWorkspace`/`ThreadTranscript` (`agent-chat/thread-message.tsx`), which statically imports `content/markdown.tsx`, which imports `remend` eagerly; see [`./content`](#content--prose--markdown) below |
616
+ | `motion` | `12.42.0` | required for both the root `.` entry and `./agent-chat` — both export `ThreadFeed`/`ThreadDetailPanel` (`agent-chat/thread-feed.tsx` / `thread-detail-panel.tsx`), which import `motion/react` eagerly; the root's `ThemeToggle` also uses it |
617
+
618
+ `./agent-chat`'s `optionalPeers` in `llms.txt`/`surfaces.ts` still lists `remend` and `motion` (npm has no per-subpath optionality in `peerDependenciesMeta`), but treat both as required when installing just this subpath — see the subpath table above.
589
619
 
590
620
  Optional peer batteries and their packages are listed per battery above.
591
621
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  source: basalt-ui
3
- description: Streaming-agent layer for basalt-ui apps — Eden-native transport, AgentPart exhaustive handling, StickToBottom, chat history, AND the multi-thread ThreadWorkspace (concurrent runs + a distilled-outcome feed + detail panel). Headless layer in ./agent; the Mantine chrome and markdown rendering (basalt-ui/content's Markdown) ship from the root entry. Covers authoring doctrine and the Eden #231 footgun.
3
+ description: Streaming-agent layer for basalt-ui apps — Eden-native transport, AgentPart exhaustive handling, StickToBottom, chat history, AND the multi-thread ThreadWorkspace (concurrent runs + a distilled-outcome feed + detail panel). Headless layer in ./agent (Mantine-free); the Mantine-coupled chrome ships from its own basalt-ui/agent-chat subpath (also re-exported from the root entry) and uses basalt-ui/content's Markdown for rendering. Covers authoring doctrine, the Eden #231 footgun, and the agent-chat lint guards (agent-resume-guard, agent-no-raw-usechat, ai-sdk-major) with their basalt-agent-allow escape token.
4
4
  paths:
5
5
  - 'src/**/*agent*'
6
6
  - 'src/**/chat*'
@@ -200,7 +200,8 @@ Built on `createPersistedState` — SSR-safe, cross-tab via the `storage` event.
200
200
  For the "many short chats" pattern — each prompt a short-lived thread, the feed showing only a
201
201
  distilled outcome (title + summary), a right-hand detail panel to open and continue — basalt-ui
202
202
  ships a full workspace. The headless multi-thread layer lives in `./agent` (Mantine-free); the
203
- ready-built Mantine chrome ships from the root `basalt-ui` entry.
203
+ ready-built Mantine chrome ships from `basalt-ui/agent-chat` (also re-exported from the root
204
+ entry).
204
205
 
205
206
  ### Headless (`basalt-ui/agent`)
206
207
 
@@ -231,10 +232,16 @@ Promise<AgentOutcome>` — the app supplies it. In production derive `{ title, s
231
232
  finished run (e.g. a structured final step of your own model) and return it. `heuristicOutcome` is
232
233
  a demo-only fallback that truncates the last assistant text — never the production path.
233
234
 
234
- ### Ready-built UI (`basalt-ui`, Mantine)
235
+ ### Ready-built UI (`basalt-ui/agent-chat`, Mantine)
236
+
237
+ The Mantine-coupled chrome (`ThreadWorkspace` and the lower-level pieces below it) ships its own
238
+ subpath, `basalt-ui/agent-chat` — added in 1.10.0. The root `basalt-ui` entry still re-exports the
239
+ same components (nothing that imported from `basalt-ui` before 1.10.0 needs to change), but a
240
+ consumer who only needs the thread-chat UI can import from `./agent-chat` directly, without pulling
241
+ in `BasaltProvider`, `BasaltShell`, the dashboard composites, or `basalt-ui/connectivity`:
235
242
 
236
243
  ```tsx
237
- import { ThreadWorkspace } from 'basalt-ui'
244
+ import { ThreadWorkspace } from 'basalt-ui/agent-chat'
238
245
  ;<ThreadWorkspace
239
246
  useThreads={useThreads}
240
247
  transport={transport}
@@ -251,8 +258,9 @@ composer) on the right, collapsing to a single pane below 768px. The lower-level
251
258
  on the shared `MOTION_*` tokens and honours `useReducedMotion`.
252
259
 
253
260
  **Boundary:** the headless layer (`createThreadsStore`, `useAgentThreadRuns`, the outcome types)
254
- stays Mantine-free in `./agent`; the components are Mantine-coupled and ship from the root entry.
255
- Never add `@mantine/*` under `src/agent/**` — it is oxlint-enforced Mantine-free.
261
+ stays Mantine-free in `./agent`; the components are Mantine-coupled and ship from `./agent-chat`
262
+ (also re-exported from the root entry). Never add `@mantine/*` under `src/agent/**` — it is
263
+ oxlint-enforced Mantine-free.
256
264
 
257
265
  **No custom part types at the workspace level.** `ThreadWorkspace`, `ThreadDetailPanel`, and
258
266
  `ThreadTranscript` render the framework's `AgentPart` union only — they are not generic over a
@@ -260,6 +268,41 @@ consumer-extended part type. If you need custom rendering for the existing part
260
268
  the headless layer instead: compose your own transcript from `PartList` (`basalt-ui/agent`) with a
261
269
  custom `components` renderer map, rather than passing a wider type through `ThreadWorkspace`.
262
270
 
271
+ ## Lint guards (agent-chat) — a SEPARATE escape token from `theme-allow`
272
+
273
+ Three oxlint rules (1.10.0) protect the streaming/resume discipline above. All three share one
274
+ escape comment, `basalt-agent-allow` — deliberately NOT `theme-allow`. The two tokens are unrelated:
275
+ a `theme-allow` comment on a color/spacing exception never suppresses one of these, and a
276
+ `basalt-agent-allow` comment never suppresses a `theme-allow` finding. Don't reach for the color
277
+ exemption to quiet a streaming guard — mark the correct token, on the correct line, and only when
278
+ you actually own the guard you're bypassing.
279
+
280
+ - **`basalt/agent-resume-guard`** — flags an unguarded `useChat({ resume: true })` or a bare
281
+ `resumeStream()` call outside `useAgentThreadRuns`. `useAgentThreadRuns` owns single-consumer
282
+ discipline and StrictMode-safe reconnection; a raw resume call re-fires on every effect re-run
283
+ (vercel/ai#7891, no merged fix upstream). Mark `basalt-agent-allow` only if you own the guard
284
+ yourself.
285
+ - **`basalt/agent-no-raw-usechat`** — flags importing `useChat`/`useCompletion` directly from
286
+ `@ai-sdk/react` or `ai/react`. Use `useAgentStream`/`useAgentThreadRuns` over `aiSdkTransport`
287
+ instead — they add unmount abort, supersede guards, and single-consumer resume that the raw hook
288
+ doesn't. Type-only imports (`import type { useChat }`) are exempt; nothing to guard against at
289
+ runtime.
290
+ - **`basalt/ai-sdk-major`** — flags a file whose nearest `package.json` declares a different `ai`
291
+ major than the one basalt-ui itself declares as a peer. A producer and consumer on different `ai`
292
+ majors can throw `'Unknown chunk type'` at runtime — this is the exact defect one production
293
+ consumer hit (one app package on `ai@5`, a sibling on `ai@7`). This rule is PER-FILE — it only
294
+ sees the nearest `package.json` to the linted file, so a lint run scoped to one workspace package
295
+ can't catch a skew against a _different_ package. `bunx basalt-ui doctor`'s `ai-major-parity`
296
+ check is the cross-package counterpart: it walks every workspace manifest and is a HARD failure,
297
+ not a lint warning — run both, they check different things. Like its two siblings above, it
298
+ honours `basalt-agent-allow` on the flagged import (or the line above it) — mark it on an
299
+ intentional producer/consumer file. The repo-wide equivalent for `doctor`'s check is
300
+ `basalt.aiMajorSkewReason` in package.json (a mandatory written reason, not a line comment) — see
301
+ the README and the `BasaltConfig` JSDoc.
302
+
303
+ None of these three rules honour `theme-allow` — only `basalt-agent-allow`, on the flagged line or
304
+ the line above it.
305
+
263
306
  ## Stream resumption — the reconnect seam
264
307
 
265
308
  basalt-ui ships the CLIENT-SIDE contract for resuming a run after a disconnect (reload, network
@@ -14,6 +14,19 @@
14
14
  * immediately above it. The three boundary rules deliberately do NOT — see their own comment for
15
15
  * why.
16
16
  *
17
+ * ── The two agent-chat guard rules (`agent-resume-guard` / `agent-no-raw-usechat`) + `ai-sdk-major` ──
18
+ * These are also correctness/architecture boundaries, not design guidance, so they do NOT honour
19
+ * `theme-allow`. All three instead honour their own line-comment escape, `basalt-agent-allow`
20
+ * (`hasAgentAllow`, a copy of `hasThemeAllow` with a different needle) — deliberately a separate
21
+ * token, so a color exemption can never double as a license to switch off a streaming guard or an
22
+ * intentional `ai` major skew. `ai-sdk-major`'s escape is line-level and per-import, distinct from
23
+ * `basalt.aiMajorSkewReason` — the repo-wide, mandatory-reason config exemption for the SAME skew
24
+ * read by `doctor`'s `ai-major-parity` check (`src/cli/index.ts`): a lint run only ever sees one
25
+ * file, so there's no reason string to echo here, just a marker on the import that intentionally
26
+ * crosses the declared skew. `ai-sdk-major` is the one rule in this file that touches the
27
+ * filesystem (reads ancestor `package.json`s, memoized per directory so a lint run does one walk
28
+ * per directory, not per file) — every other rule here is pure AST + filename-string matching.
29
+ *
17
30
  * ── The three boundary rules (`visx-boundary` / `visx-tooltip` / `token-layer-boundary`) ─────────
18
31
  * These used to be one bundled `import-boundary` rule sharing a single on/off toggle — that meant
19
32
  * a consumer disabling the one check they disagreed with silently dropped the other two as well.
@@ -58,6 +71,9 @@
58
71
  * oxlint's own parser accepts (see `ALLOWED_TOP_LEVEL_KEYS` in that script); do not add ad hoc keys
59
72
  * to that file.
60
73
  */
74
+ import { existsSync, readFileSync } from 'node:fs'
75
+ import { dirname, resolve as resolvePath } from 'node:path'
76
+ import { fileURLToPath } from 'node:url'
61
77
 
62
78
  // ── Shared helpers ──────────────────────────────────────────────────────────────────────────────
63
79
 
@@ -73,6 +89,23 @@ function hasThemeAllow(context, node) {
73
89
  )
74
90
  }
75
91
 
92
+ /**
93
+ * True when a `basalt-agent-allow` line comment sits on `node`'s own source line or the line above
94
+ * it. A copy of `hasThemeAllow` with a different needle — deliberately NOT the same token, so a
95
+ * color exemption can never double as a license to switch off a streaming guard (see the file-header
96
+ * comment for the two agent-chat rules).
97
+ */
98
+ function hasAgentAllow(context, node) {
99
+ const sourceCode = context.sourceCode ?? context.getSourceCode?.()
100
+ const comments = sourceCode?.getAllComments?.() ?? []
101
+ const nodeLine = node.loc.start.line
102
+ return comments.some(
103
+ (comment) =>
104
+ comment.value.includes('basalt-agent-allow') &&
105
+ (comment.loc.end.line === nodeLine || comment.loc.end.line === nodeLine - 1),
106
+ )
107
+ }
108
+
76
109
  function getFilename(context) {
77
110
  return context.filename ?? context.getFilename?.() ?? ''
78
111
  }
@@ -523,6 +556,249 @@ const tokenLayerBoundary = {
523
556
  },
524
557
  }
525
558
 
559
+ // ── Rule 8 — agent-resume-guard ─────────────────────────────────────────────────────────────────
560
+
561
+ const AGENT_RESUME_GUARD_MESSAGE =
562
+ 'Unguarded stream resume — useAgentThreadRuns owns single-consumer discipline and ' +
563
+ 'StrictMode-safe reconnection; a raw resume re-fires on every effect re-run (vercel/ai#7891, no ' +
564
+ "merged fix). Mark the line 'basalt-agent-allow' if you own the guard. (basalt/agent-resume-guard)"
565
+
566
+ /** True for `resumeStream()` and `<anything>.resumeStream()` call expressions. */
567
+ function isResumeStreamCall(callee) {
568
+ if (callee.type === 'Identifier') return callee.name === 'resumeStream'
569
+ return (
570
+ callee.type === 'MemberExpression' &&
571
+ callee.property.type === 'Identifier' &&
572
+ callee.property.name === 'resumeStream'
573
+ )
574
+ }
575
+
576
+ /** The `key` of an object Property as a string, covering both Identifier and Literal keys. */
577
+ function propertyKeyName(node) {
578
+ const key = node.key
579
+ if (key.type === 'Identifier') return key.name
580
+ if (key.type === 'Literal') return key.value
581
+ return undefined
582
+ }
583
+
584
+ // Deliberately NO `hasThemeAllow` escape — see basalt/visx-boundary's comment for why these
585
+ // guards use their own token (`basalt-agent-allow`, see the file-header comment) instead.
586
+ const agentResumeGuard = {
587
+ meta: {
588
+ type: 'problem',
589
+ docs: {
590
+ description:
591
+ 'Disallow unguarded useChat({ resume: true }) / resumeStream() calls outside useAgentThreadRuns.',
592
+ },
593
+ schema: [],
594
+ },
595
+ create(context) {
596
+ return {
597
+ CallExpression(node) {
598
+ const callee = node.callee
599
+
600
+ if (callee.type === 'Identifier' && callee.name === 'useChat') {
601
+ const arg0 = node.arguments[0]
602
+ if (arg0 === undefined || arg0.type !== 'ObjectExpression') return
603
+ for (const prop of arg0.properties) {
604
+ if (prop.type !== 'Property') continue
605
+ if (propertyKeyName(prop) !== 'resume') continue
606
+ if (prop.value.type !== 'Literal' || prop.value.value !== true) continue
607
+ if (hasAgentAllow(context, prop)) continue
608
+ context.report({ node: prop, message: AGENT_RESUME_GUARD_MESSAGE })
609
+ }
610
+ return
611
+ }
612
+
613
+ if (!isResumeStreamCall(callee)) return
614
+ if (hasAgentAllow(context, node)) return
615
+ context.report({ node, message: AGENT_RESUME_GUARD_MESSAGE })
616
+ },
617
+ }
618
+ },
619
+ }
620
+
621
+ // ── Rule 9 — agent-no-raw-usechat ───────────────────────────────────────────────────────────────
622
+
623
+ const AGENT_NO_RAW_USECHAT_MESSAGE =
624
+ 'Raw @ai-sdk/react useChat — use useAgentStream / useAgentThreadRuns over aiSdkTransport ' +
625
+ "(unmount abort, supersede guards, single-consumer resume). Mark the line 'basalt-agent-allow' to " +
626
+ 'opt out. (basalt/agent-no-raw-usechat)'
627
+
628
+ const RAW_USE_CHAT_SOURCES = new Set(['@ai-sdk/react', 'ai/react'])
629
+ const RAW_USE_CHAT_NAMES = new Set(['useChat', 'useCompletion'])
630
+
631
+ // Deliberately NO `hasThemeAllow` escape — see the file-header comment.
632
+ const agentNoRawUseChat = {
633
+ meta: {
634
+ type: 'problem',
635
+ docs: {
636
+ description:
637
+ 'Disallow importing useChat/useCompletion directly from @ai-sdk/react or ai/react.',
638
+ },
639
+ schema: [],
640
+ },
641
+ create(context) {
642
+ return {
643
+ ImportDeclaration(node) {
644
+ const source = node.source?.value
645
+ if (typeof source !== 'string' || !RAW_USE_CHAT_SOURCES.has(source)) return
646
+ // A whole `import type { … } from '…'` is erased at compile time — nothing to guard.
647
+ if (node.importKind === 'type') return
648
+
649
+ for (const specifier of node.specifiers) {
650
+ if (specifier.type !== 'ImportSpecifier') continue
651
+ // `import { type useChat } from '…'` — the individual specifier is type-only.
652
+ if (specifier.importKind === 'type') continue
653
+ const imported = specifier.imported
654
+ const importedName = imported.type === 'Identifier' ? imported.name : imported.value
655
+ if (typeof importedName !== 'string' || !RAW_USE_CHAT_NAMES.has(importedName)) continue
656
+ if (hasAgentAllow(context, specifier)) continue
657
+ context.report({ node: specifier, message: AGENT_NO_RAW_USECHAT_MESSAGE })
658
+ }
659
+ },
660
+ }
661
+ },
662
+ }
663
+
664
+ // ── Rule 10 — ai-sdk-major ──────────────────────────────────────────────────────────────────────
665
+
666
+ /**
667
+ * First run of digits in a semver range string (`^7.0.15` → `7`), or null if there is none.
668
+ *
669
+ * Duplicated verbatim in `src/cli/index.ts` — kept separate deliberately: this file must stay
670
+ * import-free from the package (it loads via `jsPlugins` out of a consumer's node_modules), so it
671
+ * cannot import the CLI's copy. Keep both copies in sync by hand; a future parsing fix (pre-release
672
+ * suffixes, say) must land in both.
673
+ */
674
+ function majorOf(range) {
675
+ if (typeof range !== 'string') return null
676
+ const match = range.match(/\d+/)
677
+ return match === null ? null : Number(match[0])
678
+ }
679
+
680
+ /**
681
+ * `ai`'s declared major off one parsed package.json, checked in ONE fixed order shared by every
682
+ * reader in this file (basalt's own major below, and the linted file's nearest package.json in
683
+ * `aiMajorAtPackageJson`) — `dependencies` → `devDependencies` → `peerDependencies`. Chosen
684
+ * deliberately: `dependencies` is what actually gets installed and resolved at runtime, so it wins
685
+ * over a `devDependencies` pin (test-only) or a `peerDependencies` range (a floor, not a pin). Two
686
+ * readers disagreeing on this order is exactly the kind of skew this rule exists to catch — see
687
+ * `cli/index.ts`'s `aiMajorAt`, which uses the same order for doctor's cross-package walk.
688
+ */
689
+ function aiMajorFromPkg(pkg) {
690
+ return majorOf(pkg.dependencies?.ai ?? pkg.devDependencies?.ai ?? pkg.peerDependencies?.ai)
691
+ }
692
+
693
+ /**
694
+ * basalt-ui's OWN declared `ai` peer major, read once at module load from the package.json
695
+ * sitting next to this file's `configs/` directory — `../package.json` relative to this module,
696
+ * which resolves to `packages/basalt-ui/package.json` repo-locally AND to
697
+ * `node_modules/basalt-ui/package.json` when this file runs from a consumer's installed copy
698
+ * (`configs/oxlint-plugin.js` is one directory below the package root in both places). `null` when
699
+ * unreadable or undeclared — the rule then has nothing to compare against and stays silent.
700
+ */
701
+ const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url))
702
+ const BASALT_AI_MAJOR = (() => {
703
+ try {
704
+ const pkg = JSON.parse(readFileSync(resolvePath(PLUGIN_DIR, '..', 'package.json'), 'utf8'))
705
+ // Peer-first, unlike aiMajorFromPkg's dependencies-first order: `ai` is an optional PEER of
706
+ // basalt-ui, never its runtime dependency, so the "dependencies wins because runtime" rationale
707
+ // above does not apply to this self-read — a devDependencies test pin must not masquerade as
708
+ // the declared peer major.
709
+ return majorOf(pkg.peerDependencies?.ai ?? pkg.devDependencies?.ai ?? pkg.dependencies?.ai)
710
+ } catch {
711
+ return null
712
+ }
713
+ })()
714
+
715
+ // One JSON.parse per package.json path, and one directory-walk result per starting directory — so
716
+ // a lint run over N files in the same package does one filesystem walk total, not N.
717
+ const packageJsonMajorCache = new Map()
718
+ const nearestAiMajorCache = new Map()
719
+
720
+ function aiMajorAtPackageJson(pkgPath) {
721
+ if (packageJsonMajorCache.has(pkgPath)) return packageJsonMajorCache.get(pkgPath)
722
+ let major = null
723
+ try {
724
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
725
+ major = aiMajorFromPkg(pkg)
726
+ } catch {
727
+ major = null
728
+ }
729
+ packageJsonMajorCache.set(pkgPath, major)
730
+ return major
731
+ }
732
+
733
+ /** Walks up from `startDir` to the nearest ancestor `package.json`'s declared `ai` major. */
734
+ function nearestAiMajor(startDir) {
735
+ if (nearestAiMajorCache.has(startDir)) return nearestAiMajorCache.get(startDir)
736
+
737
+ const visited = []
738
+ let dir = startDir
739
+ let result = null
740
+ for (;;) {
741
+ if (nearestAiMajorCache.has(dir)) {
742
+ result = nearestAiMajorCache.get(dir)
743
+ break
744
+ }
745
+ visited.push(dir)
746
+ const pkgPath = resolvePath(dir, 'package.json')
747
+ if (existsSync(pkgPath)) {
748
+ result = aiMajorAtPackageJson(pkgPath)
749
+ break
750
+ }
751
+ const parent = dirname(dir)
752
+ if (parent === dir) break // filesystem root — no package.json found
753
+ dir = parent
754
+ }
755
+ for (const d of visited) nearestAiMajorCache.set(d, result)
756
+ return result
757
+ }
758
+
759
+ function aiSdkMajorMessage(consumerMajor) {
760
+ return (
761
+ `This file's nearest package.json declares ai@${consumerMajor}, but basalt-ui declares the ` +
762
+ `ai@${BASALT_AI_MAJOR} peer major — a producer/consumer pair on different ai majors can throw ` +
763
+ "'Unknown chunk type' at runtime (argo defect 1: apps/api on ai@5 vs apps/dashboard on ai@7). " +
764
+ '(basalt/ai-sdk-major)'
765
+ )
766
+ }
767
+
768
+ // Honours `basalt-agent-allow` (NOT `theme-allow`) like its two agent-chat siblings above — a
769
+ // version skew is still a fact about two package.json files, but a written, intentional
770
+ // producer/consumer pair (see `doctor`'s `aiMajorSkewReason`) needs a way to say so at the import
771
+ // site too, not just be permanently blocked.
772
+ const aiSdkMajor = {
773
+ meta: {
774
+ type: 'problem',
775
+ docs: {
776
+ description:
777
+ "Disallow importing 'ai'/'@ai-sdk/*' from a package whose declared ai major differs from basalt-ui's.",
778
+ },
779
+ schema: [],
780
+ },
781
+ create(context) {
782
+ return {
783
+ ImportDeclaration(node) {
784
+ if (BASALT_AI_MAJOR === null) return
785
+ const source = node.source?.value
786
+ if (typeof source !== 'string') return
787
+ if (source !== 'ai' && !source.startsWith('@ai-sdk/')) return
788
+
789
+ const filename = getFilename(context)
790
+ if (filename.length === 0) return
791
+ const consumerMajor = nearestAiMajor(dirname(filename))
792
+ if (consumerMajor === null) return // no `ai` in the nearest package.json — nothing to compare
793
+ if (consumerMajor === BASALT_AI_MAJOR) return
794
+ if (hasAgentAllow(context, node)) return
795
+
796
+ context.report({ node, message: aiSdkMajorMessage(consumerMajor) })
797
+ },
798
+ }
799
+ },
800
+ }
801
+
526
802
  // ── Plugin export ───────────────────────────────────────────────────────────────────────────────
527
803
 
528
804
  export default {
@@ -536,5 +812,8 @@ export default {
536
812
  'visx-boundary': visxBoundary,
537
813
  'visx-tooltip': visxTooltip,
538
814
  'token-layer-boundary': tokenLayerBoundary,
815
+ 'agent-resume-guard': agentResumeGuard,
816
+ 'agent-no-raw-usechat': agentNoRawUseChat,
817
+ 'ai-sdk-major': aiSdkMajor,
539
818
  },
540
819
  }