@iloveagents/foundry-web-ui 0.1.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 +59 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +61 -0
- package/src/__tests__/no-spaces-imports.test.ts +59 -0
- package/src/__tests__/open-core-guards.test.ts +307 -0
- package/src/__tests__/theme-runtime.test.ts +81 -0
- package/src/__tests__/tool-fallback.test.tsx +109 -0
- package/src/components/ag-ui-runtime-provider.tsx +59 -0
- package/src/components/app-brand.tsx +38 -0
- package/src/components/assistant-chat.tsx +423 -0
- package/src/components/chat-attachments.tsx +68 -0
- package/src/components/chat-bubble.tsx +339 -0
- package/src/components/chat-header.tsx +44 -0
- package/src/components/client-tool-executor.tsx +230 -0
- package/src/components/collection-empty-state.tsx +37 -0
- package/src/components/collection-filter-bar.tsx +168 -0
- package/src/components/collection-search-input.tsx +41 -0
- package/src/components/collection-skeleton.tsx +55 -0
- package/src/components/collection-sort-menu.tsx +62 -0
- package/src/components/collection-surface.tsx +46 -0
- package/src/components/collection-toolbar.tsx +33 -0
- package/src/components/collection-view-toggle.tsx +61 -0
- package/src/components/confirm-dialog.tsx +49 -0
- package/src/components/confirmation-card.tsx +32 -0
- package/src/components/context-badges.tsx +124 -0
- package/src/components/context-bar.tsx +202 -0
- package/src/components/form-dialog.tsx +56 -0
- package/src/components/global-selection-popover.tsx +135 -0
- package/src/components/infinite-scroll-sentinel.tsx +48 -0
- package/src/components/json-viewer.tsx +232 -0
- package/src/components/loading-indicator.tsx +40 -0
- package/src/components/markdown-text.tsx +379 -0
- package/src/components/name-dialog.tsx +87 -0
- package/src/components/selection-popover.tsx +156 -0
- package/src/components/show-document-tool-ui.tsx +90 -0
- package/src/components/sidebar.test.tsx +182 -0
- package/src/components/sidebar.tsx +1174 -0
- package/src/components/surface-card.tsx +45 -0
- package/src/components/theme-runtime-provider.tsx +93 -0
- package/src/components/theme-toggle.tsx +27 -0
- package/src/components/tool-call-card.tsx +126 -0
- package/src/components/tool-fallback.tsx +169 -0
- package/src/components/tool-panel-layout.tsx +36 -0
- package/src/components/tool-panel.tsx +233 -0
- package/src/components/tooltip-icon-button.tsx +28 -0
- package/src/components/user-menu.tsx +60 -0
- package/src/index.ts +170 -0
- package/src/lib/__tests__/ag-ui-adapter.test.ts +182 -0
- package/src/lib/__tests__/app-store.test.ts +330 -0
- package/src/lib/__tests__/attachment-adapter.test.ts +48 -0
- package/src/lib/__tests__/chat-bubble-store.test.ts +67 -0
- package/src/lib/__tests__/client-tools.test.ts +124 -0
- package/src/lib/__tests__/dev-store.test.ts +78 -0
- package/src/lib/__tests__/nav-config.test.ts +135 -0
- package/src/lib/__tests__/nav-dnd.test.ts +24 -0
- package/src/lib/__tests__/nav-store.test.ts +59 -0
- package/src/lib/__tests__/sidebar-store.test.ts +144 -0
- package/src/lib/__tests__/theme-store.test.ts +83 -0
- package/src/lib/__tests__/tool-panel-store.test.ts +61 -0
- package/src/lib/__tests__/use-page-context.test.ts +58 -0
- package/src/lib/ag-ui-adapter.ts +362 -0
- package/src/lib/app-store.ts +294 -0
- package/src/lib/attachment-adapter.ts +92 -0
- package/src/lib/auth-provider.tsx +144 -0
- package/src/lib/chat-bubble-store.ts +46 -0
- package/src/lib/client-tools.ts +293 -0
- package/src/lib/dev-store.ts +69 -0
- package/src/lib/nav-config.ts +292 -0
- package/src/lib/nav-dnd.ts +14 -0
- package/src/lib/nav-store.ts +76 -0
- package/src/lib/sidebar-store.ts +113 -0
- package/src/lib/theme-runtime.ts +660 -0
- package/src/lib/theme-store.ts +67 -0
- package/src/lib/tool-panel-store.ts +121 -0
- package/src/lib/use-new-conversation.test.tsx +124 -0
- package/src/lib/use-new-conversation.ts +33 -0
- package/src/lib/use-page-tools.ts +40 -0
- package/src/ui/collapsible.tsx +7 -0
- package/src/ui/dialog.tsx +109 -0
- package/src/ui/dropdown-menu.tsx +71 -0
- package/src/ui/popover.tsx +34 -0
- package/src/ui/select.tsx +15 -0
- package/src/ui/tooltip.tsx +30 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +8 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
React UI core for the AG-UI protocol starter — hooks, stores, extension-point registries, assistant-ui adapter shim, ChatPanel, sidebar, layout components. Published as `@iloveagents/foundry-web-ui`.
|
|
2
|
+
|
|
3
|
+
Depends on `@iloveagents/foundry-web-primitives` (leaf primitives) and `@iloveagents/foundry-agent` (cross-runtime agent transport: AG-UI runner, vanilla stores, MSAL subpath, service-fetch factory).
|
|
4
|
+
|
|
5
|
+
# Architecture
|
|
6
|
+
|
|
7
|
+
Relative imports only (no `@/` alias). Cross-package consumers use `@iloveagents/foundry-web-ui`.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
src/
|
|
11
|
+
components/ ← UI components (sidebar, chat, tool-panel, markdown, etc.)
|
|
12
|
+
lib/ ← React-coupled stores, the assistant-ui adapter shim,
|
|
13
|
+
the React MSAL provider, hooks, utilities.
|
|
14
|
+
ui/ ← shadcn/radix primitives (tooltip, collapsible, dropdown, dialog, popover, select)
|
|
15
|
+
index.ts ← barrel export (all public API)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
# Import Boundary (enforced by guard test)
|
|
19
|
+
|
|
20
|
+
- MUST NOT import from `@lastspace/spaces-web-ui`
|
|
21
|
+
- MUST NOT use `@/` path aliases (relative imports only)
|
|
22
|
+
- MUST NOT import from `features/` or `pages/` (those are app-level)
|
|
23
|
+
- Cross-runtime transport (AG-UI runner, registry, vanilla stores, MSAL token store) lives in `@iloveagents/foundry-agent`. Re-exporting it from this package is forbidden by the no-shim rule (#90/#95).
|
|
24
|
+
|
|
25
|
+
# Extension Points
|
|
26
|
+
|
|
27
|
+
SPACES and other feature modules plug in via these registries:
|
|
28
|
+
|
|
29
|
+
- **NavItem.dnd** (`nav-config.ts`): drag-and-drop behavior on sidebar items
|
|
30
|
+
- **NavItem.dimmed / NavItem.badge** (`nav-config.ts`): visual modifiers on sidebar items
|
|
31
|
+
- **NavGroupCreateAction** (`nav-config.ts`): "+ New" actions on nav group headers
|
|
32
|
+
- **NavGroup.collapsedRail** (`nav-config.ts`): module-supplied renderer for the collapsed sidebar rail. Used when a group's collapsed view isn't a flat icon list — e.g. Spaces' "header link + active workspace's root".
|
|
33
|
+
- **NavFooterAction** (`nav-store.ts`): action buttons in the sidebar footer
|
|
34
|
+
- **Panel renderer registry** (`tool-panel-store.ts`): custom content renderers for the side panel (PDF, entity pages)
|
|
35
|
+
- **Citation handler** (`citationStore` in `@iloveagents/foundry-agent`): handles `[n]` citation clicks in markdown
|
|
36
|
+
- **Tool UI** (via `makeAssistantToolUI` from `@assistant-ui/react`): custom rendering for agent tools
|
|
37
|
+
|
|
38
|
+
# Adding Extension Points
|
|
39
|
+
|
|
40
|
+
1. Define interface in the relevant store (e.g., `PanelRendererEntry`)
|
|
41
|
+
2. Add registry array + register function to the store
|
|
42
|
+
3. Component reads from registry and delegates rendering
|
|
43
|
+
4. Feature module calls `registerX()` at init time
|
|
44
|
+
|
|
45
|
+
# AG-UI Adapter
|
|
46
|
+
|
|
47
|
+
- This package ships an `AGUIAdapterSDK` shim (`src/lib/ag-ui-adapter.ts`) that implements assistant-ui's `ChatModelAdapter` by wrapping `AGUIRunner` from `@iloveagents/foundry-agent`. The runner does the protocol; the shim translates `RunnerEvent`s into cumulative `ChatModelRunResult` yields.
|
|
48
|
+
- `@assistant-ui/react` must stay on **0.12.x or newer** — 0.11.x has a `_getInitializePromise` runtime regression.
|
|
49
|
+
- Tool UIs use `makeAssistantToolUI(...)` (the v0.12 canonical pattern). The legacy `useAssistantToolUI` hook is no longer used.
|
|
50
|
+
- Hook renames per v0.12: `useAssistantApi → useAui`, `useAssistantState → useAuiState`, `useAssistantEvent → useAuiEvent`.
|
|
51
|
+
|
|
52
|
+
# Client-Side Tools
|
|
53
|
+
|
|
54
|
+
Tools live in `clientToolRegistry` (vanilla zustand store in `@iloveagents/foundry-agent`). The runner sends schemas to the backend, intercepts `TOOL_CALL_END` for any client-side tool, executes it locally, and replays the assistant + tool messages on the next turn with the same `toolCallId`.
|
|
55
|
+
|
|
56
|
+
- **`ui_*`** — global tools registered at app init in `lib/client-tools.ts` (`ui_navigate`, `ui_set_theme`, `ui_open_panel`, `ui_select_item`, `ui_update_content`, `ui_highlight`, `ui_get_context`, `ui_confirm`).
|
|
57
|
+
- **`page_*`** — page-specific tools registered via `usePageTools([{ name, description, parameters, execute }])`.
|
|
58
|
+
|
|
59
|
+
Custom tool UIs are top-level `makeAssistantToolUI(...)` constants rendered as children inside the `AssistantRuntimeProvider` tree (see `client-tool-executor.tsx`).
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 iLoveAgents, a brand of Leitwolf GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @iloveagents/foundry-web-ui
|
|
2
|
+
|
|
3
|
+
React UI core for the AG-UI protocol agent starter. Provides hooks, stores, extension-point registries, the AG-UI adapter, ChatPanel, sidebar, and layout components. Depends on `@iloveagents/foundry-web-primitives` for leaf primitives (Button, Input, Field, Checkbox, Textarea, `cn`).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @iloveagents/foundry-web-ui @iloveagents/foundry-web-primitives
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import {
|
|
15
|
+
AGUIRuntimeProvider,
|
|
16
|
+
Sidebar,
|
|
17
|
+
ChatContent,
|
|
18
|
+
ToolPanel,
|
|
19
|
+
useAppStore,
|
|
20
|
+
usePageTools,
|
|
21
|
+
} from "@iloveagents/foundry-web-ui";
|
|
22
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Consumed as TypeScript source (no build step). Requires Tailwind v4 `@source` directives for both packages:
|
|
26
|
+
|
|
27
|
+
```css
|
|
28
|
+
@source "../../packages/web-primitives/src";
|
|
29
|
+
@source "../../packages/web-ui/src";
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Extension Points
|
|
33
|
+
|
|
34
|
+
Feature modules (like SPACES) plug into `@iloveagents/foundry-web-ui` via registries — no direct imports needed:
|
|
35
|
+
|
|
36
|
+
- **NavItem.dnd** — drag-and-drop behavior on sidebar items
|
|
37
|
+
- **Panel renderer registry** — custom content renderers for the side panel
|
|
38
|
+
- **Citation handler** — handles `[n]` citation clicks in markdown
|
|
39
|
+
- **Tool UI** — custom rendering for agent tools via `useAssistantToolUI`
|
|
40
|
+
|
|
41
|
+
See [AGENTS.md](./AGENTS.md) for architecture details and import boundary rules.
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iloveagents/foundry-web-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./styles.css": "./src/styles.css"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"react": "^19.0.0",
|
|
16
|
+
"react-dom": "^19.0.0",
|
|
17
|
+
"react-router": "^7.0.0",
|
|
18
|
+
"@assistant-ui/react": "^0.12.25",
|
|
19
|
+
"@assistant-ui/react-markdown": "^0.12.9",
|
|
20
|
+
"@ag-ui/client": "^0.0.52",
|
|
21
|
+
"@ag-ui/core": "^0.0.52",
|
|
22
|
+
"zustand": "^5.0.0",
|
|
23
|
+
"lucide-react": ">=0.400.0",
|
|
24
|
+
"@azure/msal-browser": "^5.0.0",
|
|
25
|
+
"@azure/msal-react": "^5.0.0",
|
|
26
|
+
"@iloveagents/foundry-agent": "0.1.0",
|
|
27
|
+
"@iloveagents/foundry-web-primitives": "0.1.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@azure/msal-browser": {
|
|
31
|
+
"optional": true
|
|
32
|
+
},
|
|
33
|
+
"@azure/msal-react": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@radix-ui/react-checkbox": "^1.3.3",
|
|
39
|
+
"@radix-ui/react-collapsible": "^1.1.0",
|
|
40
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
41
|
+
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
|
42
|
+
"@radix-ui/react-popover": "^1.1.15",
|
|
43
|
+
"@radix-ui/react-slot": "^1.2.0",
|
|
44
|
+
"@radix-ui/react-tooltip": "^1.2.0",
|
|
45
|
+
"class-variance-authority": "^0.7.0",
|
|
46
|
+
"clsx": "^2.1.0",
|
|
47
|
+
"tailwind-merge": "^3.5.0",
|
|
48
|
+
"react-markdown": "^10.0.0",
|
|
49
|
+
"remark-gfm": "^4.0.0",
|
|
50
|
+
"shiki": "^4.0.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"typescript": "~5.9.3",
|
|
54
|
+
"@types/react": "^19.2.2",
|
|
55
|
+
"vitest": "^4.1.4",
|
|
56
|
+
"jsdom": "^28.1.0"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"test:unit": "vitest run"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard test: @iloveagents/foundry-web-ui must never import from @lastspace/spaces-web-ui.
|
|
3
|
+
* This prevents SPACES code from leaking back into the React UI core package.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
function findFiles(dir: string, ext: string[]): string[] {
|
|
10
|
+
const results: string[] = [];
|
|
11
|
+
for (const entry of readdirSync(dir)) {
|
|
12
|
+
const full = join(dir, entry);
|
|
13
|
+
if (entry === "node_modules" || entry === "__tests__") continue;
|
|
14
|
+
if (statSync(full).isDirectory()) {
|
|
15
|
+
results.push(...findFiles(full, ext));
|
|
16
|
+
} else if (ext.some((e) => full.endsWith(e))) {
|
|
17
|
+
results.push(full);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("@iloveagents/foundry-web-ui import boundaries", () => {
|
|
24
|
+
const srcDir = join(import.meta.dirname!, "..");
|
|
25
|
+
const files = findFiles(srcDir, [".ts", ".tsx"]);
|
|
26
|
+
|
|
27
|
+
it("must not import from @lastspace/spaces-web-ui", () => {
|
|
28
|
+
const violations: string[] = [];
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
const content = readFileSync(file, "utf-8");
|
|
31
|
+
if (content.includes("@lastspace/spaces-web-ui")) {
|
|
32
|
+
violations.push(file.replace(srcDir + "/", ""));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
expect(violations).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("must not import from @content-editor/react (legacy name)", () => {
|
|
39
|
+
const violations: string[] = [];
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
const content = readFileSync(file, "utf-8");
|
|
42
|
+
if (content.includes("@content-editor/react")) {
|
|
43
|
+
violations.push(file.replace(srcDir + "/", ""));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
expect(violations).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("must not use @/ path aliases (uses relative imports)", () => {
|
|
50
|
+
const violations: string[] = [];
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
const content = readFileSync(file, "utf-8");
|
|
53
|
+
if (/from ["']@\//.test(content)) {
|
|
54
|
+
violations.push(file.replace(srcDir + "/", ""));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
expect(violations).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open-core packaging guards.
|
|
3
|
+
*
|
|
4
|
+
* Three data-driven guards (the customer-isolation + Python guards from the
|
|
5
|
+
* lastspace-origin version were dropped — foundry-ui is TS-only, no
|
|
6
|
+
* customer-specific code lives here):
|
|
7
|
+
*
|
|
8
|
+
* 1. Banned imports inside `packages/agent/src/` — no React, no DOM, no
|
|
9
|
+
* `@assistant-ui/*`, no bare `"zustand"`. The MSAL subpath is the
|
|
10
|
+
* single exception that may import `@azure/msal-browser`.
|
|
11
|
+
* 2. Open-core import-direction graph — each `@iloveagents/foundry-*`
|
|
12
|
+
* package may only import from a declared subset of siblings.
|
|
13
|
+
* 3. File-count ceiling on `packages/web-shell/src` — the shell stays
|
|
14
|
+
* thin (composition layer, not application surface).
|
|
15
|
+
* 4. Version-pin parity — shared deps in `pnpm-workspace.yaml` overrides
|
|
16
|
+
* stay in sync across workspace `package.json` files.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
20
|
+
import { join, relative } from "node:path";
|
|
21
|
+
|
|
22
|
+
const ROOT = join(import.meta.dirname!, "../../../..");
|
|
23
|
+
|
|
24
|
+
// ----------------------------------------------------------------------------
|
|
25
|
+
// Configs
|
|
26
|
+
// ----------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Open-core package import graph. For each package, list which `@iloveagents/*`
|
|
30
|
+
* packages it MAY import. Empty array = none.
|
|
31
|
+
*
|
|
32
|
+
* Packages that do not yet exist on disk are skipped at runtime by
|
|
33
|
+
* `findPackageDir` so future packages can be wired in here ahead of their
|
|
34
|
+
* extraction.
|
|
35
|
+
*/
|
|
36
|
+
const OPEN_CORE_GRAPH: Record<string, string[]> = {
|
|
37
|
+
"@iloveagents/foundry-agent": [],
|
|
38
|
+
"@iloveagents/foundry-web-primitives": [],
|
|
39
|
+
"@iloveagents/foundry-web-ui": [
|
|
40
|
+
"@iloveagents/foundry-web-primitives",
|
|
41
|
+
"@iloveagents/foundry-agent",
|
|
42
|
+
],
|
|
43
|
+
"@iloveagents/foundry-web-shell": [
|
|
44
|
+
"@iloveagents/foundry-web-primitives",
|
|
45
|
+
"@iloveagents/foundry-web-ui",
|
|
46
|
+
"@iloveagents/foundry-agent",
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Banned bare-package imports inside `packages/agent/src/`.
|
|
52
|
+
*
|
|
53
|
+
* `@iloveagents/foundry-agent` is the cross-runtime transport — pure TS, zero React,
|
|
54
|
+
* zero `@assistant-ui/*`. The MSAL subpath (`packages/agent/src/msal/`) may
|
|
55
|
+
* pull in `@azure/msal-browser` (dynamic import keeps it optional). Bare
|
|
56
|
+
* `"zustand"` is banned everywhere — must use `"zustand/vanilla"`.
|
|
57
|
+
*/
|
|
58
|
+
const AGENT_BANNED_IMPORTS = [
|
|
59
|
+
{ specifier: "react", scope: "all" as const },
|
|
60
|
+
{ specifier: "react-dom", scope: "all" as const },
|
|
61
|
+
{ specifier: "react/jsx-runtime", scope: "all" as const },
|
|
62
|
+
{ specifierPrefix: "@assistant-ui/", scope: "all" as const },
|
|
63
|
+
{ specifier: "@azure/msal-react", scope: "all" as const },
|
|
64
|
+
// bare "zustand" is the React entry — agent must use "zustand/vanilla".
|
|
65
|
+
{ specifierExact: "zustand", scope: "all" as const },
|
|
66
|
+
// msal-browser is allowed only under src/msal/.
|
|
67
|
+
{ specifier: "@azure/msal-browser", scope: "non-msal" as const },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/** File-count ceiling: `packages/web-shell/src` is hard-capped at 15 — the
|
|
71
|
+
* shell is a thin composition layer (bootstrapShell + ChatModule wiring +
|
|
72
|
+
* auth provider seam), not application surface. Growth indicates leakage.
|
|
73
|
+
*/
|
|
74
|
+
const FILE_COUNT_CEILINGS = [
|
|
75
|
+
{ path: "packages/web-shell/src", ceiling: 15, hardFail: true },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Shared deps where all consumers must agree on the major version.
|
|
80
|
+
* Rooted in `pnpm-workspace.yaml` overrides — once a customer's package.json
|
|
81
|
+
* declares an incompatible major, pnpm overrides hide the drift at install
|
|
82
|
+
* time, but the public package metadata is wrong.
|
|
83
|
+
*/
|
|
84
|
+
const SHARED_DEPS_REQUIRED_MAJOR = [
|
|
85
|
+
{ name: "react-router", expectedMajor: "7" },
|
|
86
|
+
{ name: "@assistant-ui/react", expectedMajor: "0" },
|
|
87
|
+
{ name: "@ag-ui/client", expectedMajor: "0" },
|
|
88
|
+
{ name: "@ag-ui/core", expectedMajor: "0" },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// ----------------------------------------------------------------------------
|
|
92
|
+
// Helpers
|
|
93
|
+
// ----------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function findFiles(dir: string, exts: string[], skip: Set<string>): string[] {
|
|
96
|
+
if (!existsSync(dir)) return [];
|
|
97
|
+
const out: string[] = [];
|
|
98
|
+
for (const entry of readdirSync(dir)) {
|
|
99
|
+
if (skip.has(entry)) continue;
|
|
100
|
+
const full = join(dir, entry);
|
|
101
|
+
const stat = statSync(full);
|
|
102
|
+
if (stat.isDirectory()) out.push(...findFiles(full, exts, skip));
|
|
103
|
+
else if (exts.some((e) => full.endsWith(e))) out.push(full);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findPackageDir(name: string): string | null {
|
|
109
|
+
const pkgDir = join(ROOT, "packages");
|
|
110
|
+
if (!existsSync(pkgDir)) return null;
|
|
111
|
+
for (const entry of readdirSync(pkgDir)) {
|
|
112
|
+
const candidate = join(pkgDir, entry);
|
|
113
|
+
const pkgJsonPath = join(candidate, "package.json");
|
|
114
|
+
if (existsSync(pkgJsonPath)) {
|
|
115
|
+
try {
|
|
116
|
+
const json = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
|
|
117
|
+
if (json.name === name) return candidate;
|
|
118
|
+
} catch {
|
|
119
|
+
/* ignore malformed */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function importsOf(content: string): string[] {
|
|
127
|
+
const out: string[] = [];
|
|
128
|
+
// ES imports + dynamic imports + re-exports
|
|
129
|
+
const re = /(?:import|export|require)\s*[^"';]*?["']([^"']+)["']/g;
|
|
130
|
+
let m: RegExpExecArray | null;
|
|
131
|
+
while ((m = re.exec(content)) !== null) out.push(m[1]);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ----------------------------------------------------------------------------
|
|
136
|
+
// Tests
|
|
137
|
+
// ----------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
describe("open-core guards", () => {
|
|
140
|
+
// 1. Agent package: no React / no assistant-ui / no bare zustand
|
|
141
|
+
describe("@iloveagents/foundry-agent banned-import guard", () => {
|
|
142
|
+
const agentRoot = findPackageDir("@iloveagents/foundry-agent");
|
|
143
|
+
|
|
144
|
+
if (!agentRoot) {
|
|
145
|
+
it.skip("agent package not yet extracted", () => undefined);
|
|
146
|
+
} else {
|
|
147
|
+
const files = findFiles(
|
|
148
|
+
join(agentRoot, "src"),
|
|
149
|
+
[".ts", ".tsx"],
|
|
150
|
+
new Set(["node_modules", "dist", "__tests__"]),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
function isUnderMsal(filePath: string): boolean {
|
|
154
|
+
const rel = relative(agentRoot!, filePath);
|
|
155
|
+
return rel.startsWith(`src/msal/`) || rel.startsWith(`src${"/"}msal${"/"}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const rule of AGENT_BANNED_IMPORTS) {
|
|
159
|
+
const label =
|
|
160
|
+
(rule as { specifier?: string }).specifier ??
|
|
161
|
+
(rule as { specifierPrefix?: string }).specifierPrefix ??
|
|
162
|
+
(rule as { specifierExact?: string }).specifierExact!;
|
|
163
|
+
|
|
164
|
+
it(`no source file imports "${label}" (scope: ${rule.scope})`, () => {
|
|
165
|
+
const violations: string[] = [];
|
|
166
|
+
for (const file of files) {
|
|
167
|
+
if (rule.scope === "non-msal" && isUnderMsal(file)) continue;
|
|
168
|
+
const content = readFileSync(file, "utf-8");
|
|
169
|
+
for (const imp of importsOf(content)) {
|
|
170
|
+
const matches =
|
|
171
|
+
("specifierExact" in rule && imp === rule.specifierExact) ||
|
|
172
|
+
("specifier" in rule && imp === rule.specifier) ||
|
|
173
|
+
("specifierPrefix" in rule &&
|
|
174
|
+
rule.specifierPrefix !== undefined &&
|
|
175
|
+
imp.startsWith(rule.specifierPrefix));
|
|
176
|
+
if (matches) violations.push(`${relative(ROOT, file)} -> ${imp}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
expect(
|
|
180
|
+
violations,
|
|
181
|
+
`${label} import leaked into packages/agent/src/${
|
|
182
|
+
rule.scope === "non-msal" ? " (outside msal/)" : ""
|
|
183
|
+
}`,
|
|
184
|
+
).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// 2. Open-core import graph
|
|
191
|
+
describe("open-core package import graph", () => {
|
|
192
|
+
for (const [pkg, allowed] of Object.entries(OPEN_CORE_GRAPH)) {
|
|
193
|
+
it(`${pkg} only imports from [${allowed.join(", ") || "none"}]`, () => {
|
|
194
|
+
const dir = findPackageDir(pkg);
|
|
195
|
+
if (!dir) return; // package not yet extracted — skip
|
|
196
|
+
const files = findFiles(
|
|
197
|
+
join(dir, "src"),
|
|
198
|
+
[".ts", ".tsx"],
|
|
199
|
+
new Set(["node_modules", "dist", "__tests__"]),
|
|
200
|
+
);
|
|
201
|
+
const violations: string[] = [];
|
|
202
|
+
for (const file of files) {
|
|
203
|
+
const content = readFileSync(file, "utf-8");
|
|
204
|
+
for (const imp of importsOf(content)) {
|
|
205
|
+
// Foundry-ui packages must never import from @lastspace/* (commercial
|
|
206
|
+
// downstream); allow-listing is enforced for sibling @iloveagents/* deps.
|
|
207
|
+
if (imp.startsWith("@lastspace/")) {
|
|
208
|
+
violations.push(`${relative(ROOT, file)} -> ${imp} (lastspace import forbidden)`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!imp.startsWith("@iloveagents/")) continue;
|
|
212
|
+
// Self-imports allowed (subpath exports etc).
|
|
213
|
+
if (imp === pkg || imp.startsWith(`${pkg}/`)) continue;
|
|
214
|
+
// Strip subpath off the imported package.
|
|
215
|
+
const importedPkg = imp.split("/").slice(0, 2).join("/");
|
|
216
|
+
if (!allowed.includes(importedPkg)) {
|
|
217
|
+
violations.push(`${relative(ROOT, file)} -> ${imp}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
expect(violations, `${pkg} imported a forbidden package`).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// 3. File-count ceiling (warning + optional hard-fail)
|
|
227
|
+
describe("file-count ceilings on host directories", () => {
|
|
228
|
+
for (const { path, ceiling, hardFail } of FILE_COUNT_CEILINGS) {
|
|
229
|
+
it(`${path} ${hardFail ? "must" : "should"} stay ≤ ${ceiling} files`, () => {
|
|
230
|
+
const dir = join(ROOT, path);
|
|
231
|
+
if (!existsSync(dir)) return;
|
|
232
|
+
const files = findFiles(
|
|
233
|
+
dir,
|
|
234
|
+
[".ts", ".tsx", ".py"],
|
|
235
|
+
new Set(["node_modules", ".venv", "__pycache__", "dist", "__tests__"]),
|
|
236
|
+
);
|
|
237
|
+
if (hardFail) {
|
|
238
|
+
expect(
|
|
239
|
+
files.length,
|
|
240
|
+
`${path} has ${files.length} files; ceiling is ${ceiling}. Move features into packages.`,
|
|
241
|
+
).toBeLessThanOrEqual(ceiling);
|
|
242
|
+
} else {
|
|
243
|
+
// Warning-mode: log if over ceiling but don't fail. Flip to hardFail
|
|
244
|
+
// once host-shell extraction (#98) lands.
|
|
245
|
+
if (files.length > ceiling) {
|
|
246
|
+
// eslint-disable-next-line no-console
|
|
247
|
+
console.warn(
|
|
248
|
+
`[file-count guard] ${path} has ${files.length} files (ceiling ${ceiling}). Hard-fail enabled once #98 lands.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
expect(files.length).toBeGreaterThanOrEqual(0); // always passes; just records
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// 4. Version-pin parity
|
|
258
|
+
describe("shared deps stay in sync across workspace package.jsons", () => {
|
|
259
|
+
function workspacePackageJsons(): { dir: string; json: Record<string, unknown> }[] {
|
|
260
|
+
const out: { dir: string; json: Record<string, unknown> }[] = [];
|
|
261
|
+
for (const base of ["packages"]) {
|
|
262
|
+
const baseDir = join(ROOT, base);
|
|
263
|
+
if (!existsSync(baseDir)) continue;
|
|
264
|
+
for (const entry of readdirSync(baseDir)) {
|
|
265
|
+
const dir = join(baseDir, entry);
|
|
266
|
+
const pkgJson = join(dir, "package.json");
|
|
267
|
+
if (existsSync(pkgJson)) {
|
|
268
|
+
try {
|
|
269
|
+
out.push({ dir, json: JSON.parse(readFileSync(pkgJson, "utf-8")) });
|
|
270
|
+
} catch {
|
|
271
|
+
/* ignore */
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function depMajor(spec: string): string | null {
|
|
280
|
+
// Strip leading ^, ~, >=, =, etc.
|
|
281
|
+
const m = spec.match(/(\d+)/);
|
|
282
|
+
return m ? m[1] : null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const { name, expectedMajor } of SHARED_DEPS_REQUIRED_MAJOR) {
|
|
286
|
+
it(`${name} declared at major ${expectedMajor} in every package.json that declares it`, () => {
|
|
287
|
+
const violations: string[] = [];
|
|
288
|
+
for (const { dir, json } of workspacePackageJsons()) {
|
|
289
|
+
const buckets = ["dependencies", "peerDependencies", "devDependencies"] as const;
|
|
290
|
+
for (const bucket of buckets) {
|
|
291
|
+
const deps = (json[bucket] ?? {}) as Record<string, string>;
|
|
292
|
+
const spec = deps[name];
|
|
293
|
+
if (!spec) continue;
|
|
294
|
+
const major = depMajor(spec);
|
|
295
|
+
if (major !== expectedMajor) {
|
|
296
|
+
violations.push(`${relative(ROOT, dir)} ${bucket}.${name}=${spec}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
expect(
|
|
301
|
+
violations,
|
|
302
|
+
`${name} version drift detected; pnpm-workspace.yaml override hides it at install time but published metadata is wrong`,
|
|
303
|
+
).toEqual([]);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getThemePreset,
|
|
4
|
+
mergeThemeDefinitions,
|
|
5
|
+
resolveThemeRuntime,
|
|
6
|
+
} from "../lib/theme-runtime.ts";
|
|
7
|
+
|
|
8
|
+
describe("theme runtime", () => {
|
|
9
|
+
it("merges sparse definitions in precedence order", () => {
|
|
10
|
+
const merged = mergeThemeDefinitions(
|
|
11
|
+
{
|
|
12
|
+
light: {
|
|
13
|
+
primary: "oklch(0.4 0.2 280)",
|
|
14
|
+
foreground: "oklch(0.2 0.01 280)",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
light: {
|
|
19
|
+
primary: "oklch(0.5 0.19 260)",
|
|
20
|
+
},
|
|
21
|
+
status: {
|
|
22
|
+
light: {
|
|
23
|
+
success: "oklch(0.6 0.15 150)",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(merged.light?.primary).toBe("oklch(0.5 0.19 260)");
|
|
30
|
+
expect(merged.light?.foreground).toBe("oklch(0.2 0.01 280)");
|
|
31
|
+
expect(merged.status?.light?.success).toBe("oklch(0.6 0.15 150)");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("resolves scoped layers on top of the preset", () => {
|
|
35
|
+
const runtime = resolveThemeRuntime(
|
|
36
|
+
[
|
|
37
|
+
{
|
|
38
|
+
id: "global-theme",
|
|
39
|
+
basePreset: "foundry-violet",
|
|
40
|
+
definition: {
|
|
41
|
+
light: {
|
|
42
|
+
primary: "oklch(0.45 0.18 300)",
|
|
43
|
+
},
|
|
44
|
+
sidebar: {
|
|
45
|
+
light: {
|
|
46
|
+
sidebar: "oklch(0.98 0.01 300)",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "workspace-theme",
|
|
53
|
+
definition: {
|
|
54
|
+
light: {
|
|
55
|
+
primary: "oklch(0.52 0.16 240)",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
"light",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(runtime.mode).toBe("light");
|
|
64
|
+
expect(runtime.sources.map((layer) => layer.id)).toEqual([
|
|
65
|
+
"global-theme",
|
|
66
|
+
"workspace-theme",
|
|
67
|
+
]);
|
|
68
|
+
expect(runtime.definition.light?.primary).toBe("oklch(0.52 0.16 240)");
|
|
69
|
+
expect(runtime.variables["--primary" as keyof typeof runtime.variables]).toBe(
|
|
70
|
+
"oklch(0.52 0.16 240)",
|
|
71
|
+
);
|
|
72
|
+
expect(runtime.variables["--sidebar" as keyof typeof runtime.variables]).toBe(
|
|
73
|
+
"oklch(0.98 0.01 300)",
|
|
74
|
+
);
|
|
75
|
+
expect(runtime.variables["--success" as keyof typeof runtime.variables]).toBeTruthy();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("falls back to the default preset for unknown names", () => {
|
|
79
|
+
expect(getThemePreset("unknown-preset")).toEqual(getThemePreset("foundry-violet"));
|
|
80
|
+
});
|
|
81
|
+
});
|