This commit is contained in:
Ole
2026-05-31 20:25:41 +00:00
commit 0a07ab8593
275 changed files with 52660 additions and 0 deletions
+413
View File
@@ -0,0 +1,413 @@
---
name: code-quality
description: "MUST be used whenever reviewing a Flows app for code quality, maintainability, or clean code issues — before a PR review, after a feature is complete, or when the user asks for a code review. Do NOT skip linting steps. Triggers: code quality, code review, clean code, refactor, maintainability, technical debt, any type, naming, dead code, duplication, DRY, single responsibility, component size, lint, linting, TypeScript strict, dependency injection, file structure."
allowed-tools: Read, Glob, Grep, Shell, Write
metadata:
argument-hint: "[file, directory, or PR branch to review — e.g. 'src/components/AssetPanel.tsx']"
---
# Code Quality Review
Review **$ARGUMENTS** (or the whole app if no argument is given) for code quality issues. Work through every step below in order and report all findings with file paths and line numbers.
---
## Step 1 — Run the linter first
Before reading any code manually, get a baseline from the automated tools:
```bash
pnpm run lint
```
List every error and warning. Fix all errors before proceeding — lint errors are not negotiable. Warnings should be reviewed and resolved unless there is a documented exception.
Also run the TypeScript compiler in strict mode to surface any hidden type issues:
```bash
pnpm exec tsc --noEmit
```
List every type error. These must be fixed.
---
## Step 2 — TypeScript type safety
### 2a — Eliminate `any` types
Search for `any` usage across the codebase:
```bash
grep -rn --include="*.ts" --include="*.tsx" -E ": any|as any|<any>" src/
```
For each hit, replace with the correct type. Common substitutions:
| Instead of | Use |
|------------|-----|
| `any` for unknown external data | `unknown` + type guard or Zod parse |
| `any` for event handlers | `React.ChangeEvent<HTMLInputElement>`, `React.MouseEvent`, etc. |
| `any` for CDF responses | The SDK's own response types (import from `@cognite/sdk`) |
| `any[]` for arrays | `T[]` with the correct generic |
| `as any` casts | Proper type narrowing or explicit overloaded function signature |
The goal is zero `any` in `src/`. If a third-party library forces it, wrap the call in a typed adapter function so `any` does not leak into the app.
### 2b — Make impossible states unrepresentable
Use the type system to make invalid states fail at compile time. Fewer reachable states = easier code to read and change.
**Branded types** — brand primitives so they can't be mixed up. Validate once at the boundary; downstream code trusts the type.
```ts
type PhoneNumber = string & { __brand: "PhoneNumber" };
function parsePhone(input: string): PhoneNumber {
if (!/^\+?\d{10,15}$/.test(input)) throw new Error(`Invalid: ${input}`);
return input as PhoneNumber;
}
```
If the project uses a library with native branded-type support (e.g. Effect), use their primitives instead of rolling your own.
**Discriminated unions over flag bags** — replace boolean/optional combos with an exhaustive union:
```ts
// Don't — invalid combos representable
type State = { loading: boolean; user?: User; error?: string };
// Do — only valid states exist
type State =
| { status: "loading" }
| { status: "success"; user: User }
| { status: "error"; error: string };
```
Search for flag-bag patterns:
```bash
grep -rn --include="*.ts" --include="*.tsx" -E "loading\?|isLoading.*isError|isSuccess.*isError" src/
```
Flag every type that combines boolean flags where only certain combos are valid. These should be discriminated unions.
### 2c — Let types flow end-to-end
DB schema → server → client should share types without manual duplication. Don't restate types you can derive — reach for `Pick`, `Omit`, `Parameters`, `ReturnType`, `Awaited`, `typeof` before writing a new interface.
```ts
// Don't — duplicate shape, drifts when the row changes
type UserSummary = { id: string; email: Email };
function renderUser(u: UserSummary) { /* ... */ }
// Do — derive from the source of truth
type User = Awaited<ReturnType<typeof db.query.users.findFirst>>;
function renderUser(u: Pick<User, "id" | "email">) { /* ... */ }
```
```bash
# Find manually duplicated type shapes
grep -rn --include="*.ts" --include="*.tsx" -E "^(export )?type \w+Summary|^(export )?interface \w+DTO" src/
```
Flag interfaces that manually restate fields already present on an SDK or DB type — these should use `Pick`/`Omit` instead.
### 2d — Pass objects, not positional arguments
Functions with two or more parameters of the same primitive type should receive a named-property object so callers can't silently swap arguments.
```ts
// Don't — swap two args, still compiles
sendEmail("Welcome!", "Hi there");
// Do — order-independent, self-documenting
sendEmail({ to: "alice@x.com", subject: "Welcome!", body: "Hi there" });
```
```bash
# Find functions with multiple string/number parameters (potential swap bugs)
grep -rn --include="*.ts" --include="*.tsx" -E "^\s*(export\s+)?(function|const)\s+\w+\s*\([^)]*string[^)]*string" src/
```
---
## Step 3 — Check component size and single responsibility
List all `.tsx` files with their line counts:
```bash
node -e "const fs=require('fs'),path=require('path');function walk(d){return fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>{const p=path.join(d,e.name);return e.isDirectory()?walk(p):p.endsWith('.tsx')?[p]:[]})}walk('src').map(p=>({p,l:fs.readFileSync(p,'utf8').split('\n').length})).sort((a,b)=>b.l-a.l).forEach(({l,p})=>console.log(l,p))"
```
Flag every component file over **150 lines**. For each, read it and check:
- Does it do more than one thing? (fetch data AND render UI AND handle form state)
- Can the fetch logic move to a custom hook (`useAssetData`)?
- Can sub-sections be extracted as named sub-components?
Apply the split only when it creates a genuinely cleaner separation — do not split for the sake of line count alone. A well-named 200-line component is better than three poorly-named 60-line ones.
---
## Step 4 — Find and remove duplicate logic (DRY)
Search for copy-pasted patterns across hooks, utilities, and components:
```bash
# Find repeated fetch patterns
grep -rn --include="*.ts" --include="*.tsx" -E "sdk\.(assets|timeseries|events|files)\.(list|retrieve)" src/
# Find repeated formatting functions
grep -rn --include="*.ts" --include="*.tsx" -E "toLocaleDateString|toLocaleString|new Date\(" src/
# Find repeated className strings longer than 40 chars
grep -rn --include="*.tsx" -E 'className="[^"]{40,}"' src/
```
For each set of duplicates:
- Extract to `src/utils/` if it is a pure function
- Extract to `src/hooks/` if it contains React state or effects
- Extract to a shared component if it is JSX
---
## Step 5 — Enforce dependency injection for external calls
Components and hooks must not import the CDF client directly. The SDK client must be obtained from context (via `useCogniteClient()` or a prop) so the component is testable in isolation.
```bash
grep -rn --include="*.ts" --include="*.tsx" -E "new CogniteClient|createCogniteClient" src/
```
Flag any direct client construction outside of the app's bootstrap / auth setup file. The pattern should always be:
```ts
// GOOD — client comes from context
export function useMyData() {
const sdk = useCogniteClient(); // from Flows auth context
// ...
}
// BAD — direct construction inside a hook or component
const sdk = new CogniteClient({ project: "my-project", ... });
```
Similarly, Atlas tools should receive their dependencies via `execute`'s closure over a hook-provided ref, not by importing a global singleton.
---
## Step 6 — Verify coding patterns and testability
Check that the codebase follows the three core patterns required by the Flows app review process. These patterns keep code testable, maintainable, and consistent.
### 6a — Dependency injection via React context
Hooks must declare their dependencies through a context type and consume them via `useContext`, not by importing them directly. This enables testing without module-level mocks.
```bash
# Find hooks that import other hooks/services directly (potential DI violation)
grep -rn --include="*.ts" --include="*.tsx" -E "^import.*from\s+['\"]\.\./" src/hooks/
# Find hooks that use useContext for dependency injection (good pattern)
grep -rn --include="*.ts" --include="*.tsx" "useContext" src/hooks/
```
The preferred pattern:
```typescript
// GOOD — injectable via context
const defaultDependencies = { useDataSource, useAnalytics };
export type UseMyHookContextType = typeof defaultDependencies;
export const UseMyHookContext = createContext<UseMyHookContextType>(defaultDependencies);
export function useMyHook() {
const { useDataSource } = useContext(UseMyHookContext);
}
// BAD — hard-coded import, requires vi.mock to test
import { useDataSource } from '../data/useDataSource';
export function useMyHook() { const data = useDataSource(); }
```
For non-React code (utilities, services), use **factory functions with partial dependency overrides**:
```typescript
type Deps = { serviceFactory: () => SomeService };
const defaultDeps: Deps = { serviceFactory: () => new SomeServiceImpl() };
export const doSomething = async (props: Props, depOverrides?: Partial<Deps>) => {
const deps = { ...defaultDeps, ...depOverrides };
const service = deps.serviceFactory();
};
```
Flag every hook that imports dependencies directly instead of receiving them through context. These are testability concerns even if tests exist today.
### 6b — Interface-based services
Service classes must implement explicit TypeScript interfaces. This keeps production code substitutable and test doubles type-safe.
```bash
# Find service/class definitions and check for interface implementations
grep -rn --include="*.ts" --include="*.tsx" -E "class\s+\w+(Service|Client|Repository|Manager)" src/
# Find unsafe casts in production AND test code
grep -rn --include="*.ts" --include="*.tsx" "as unknown as" src/
```
Flag:
- Service classes that do not implement an explicit interface
- `as unknown as T` casts in either production or test code — this signals poor interface design
### 6c — ViewModel pattern
Page-level hooks (`useSomethingViewModel`) must separate business logic from presentation. UI components receive data and callbacks only; they contain no data-fetching, side-effect logic, or direct SDK calls.
```bash
# Find page/view components
grep -rn --include="*.tsx" --include="*.ts" -l "useQuery\|useMutation\|sdk\.\|client\." src/pages/ src/views/ 2>/dev/null
# Find ViewModel hooks
grep -rn --include="*.ts" --include="*.tsx" -l "ViewModel" src/hooks/ 2>/dev/null
```
Flag:
- Page components that contain `useQuery`, `useMutation`, or direct SDK calls — this logic should be in a ViewModel hook
- Missing ViewModel hooks for pages with non-trivial data logic
### 6d — Test mock quality
```bash
# Find vi.mock usage — each should have a comment justifying why context injection wasn't used
grep -rn --include="*.ts" --include="*.tsx" "vi\.mock" src/
# Find unsafe test casts
grep -rn --include="*.ts" --include="*.tsx" "as unknown as" src/ | grep -E "\.test\.|\.spec\."
```
Flag:
- `vi.mock` usage without a justification comment explaining why context injection was not possible
- `as unknown as T` casts in test files — signals poor interface design in the production code
---
## Step 7 — Check naming conventions
Read a representative sample of files and verify:
| Artifact | Convention | Examples |
|----------|-----------|---------|
| Files & directories | `kebab-case` | `asset-panel.tsx`, `use-asset-data.ts` |
| React components | `PascalCase` | `AssetPanel`, `NavigationBar` |
| Variables, functions, hooks | `camelCase` | `isLoading`, `fetchAssets`, `useAssetData` |
| Constants (module-level) | `SCREAMING_SNAKE_CASE` | `MAX_ITEMS`, `AGENT_EXTERNAL_ID` |
| TypeScript types & interfaces | `PascalCase` | `AssetNode`, `ChartConfig` |
| Boolean variables | Auxiliary verb prefix | `isLoading`, `hasError`, `canEdit` |
Search for common violations:
```bash
# TSX components not in PascalCase (filename starts with lowercase)
node -e "const fs=require('fs'),path=require('path');function walk(d){return fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>{const p=path.join(d,e.name);return e.isDirectory()?walk(p):p.endsWith('.tsx')?[p]:[]})}walk('src').filter(p=>/^[a-z]/.test(path.basename(p))).forEach(p=>console.log(p))"
# Hook files not prefixed with "use"
node -e "const fs=require('fs');fs.readdirSync('src/hooks').filter(f=>f.endsWith('.ts')&&!f.startsWith('use')).forEach(f=>console.log('src/hooks/'+f))"
```
---
## Step 8 — Remove dead code
```powershell
# Find commented-out code blocks (3+ consecutive commented lines)
Get-ChildItem -Recurse -Include "*.ts","*.tsx" src | ForEach-Object {
$file = $_; $lines = Get-Content $file.FullName
$count = 0; $startLine = 0
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match '^\s*//') {
if ($count -eq 0) { $startLine = $i + 1 }
$count++
} else {
if ($count -ge 3) { "$($file.FullName):$startLine$count consecutive comment lines" }
$count = 0
}
}
if ($count -ge 3) { "$($file.FullName):$startLine$count consecutive comment lines" }
}
# Find console.log/debug statements
grep -rn --include="*.tsx" --include="*.ts" -E "console\.(log|debug|warn|error|info)" src/
# Find TODO/FIXME/HACK comments
grep -rn --include="*.tsx" --include="*.ts" -E "(TODO|FIXME|HACK|XXX):" src/
```
Search for unreachable pages (routes defined in the router but whose component is never imported or rendered) and entirely unused files:
```bash
# Find all .ts/.tsx files and check if they are imported anywhere
for file in $(find src -name "*.ts" -o -name "*.tsx" | grep -v ".test." | grep -v ".spec." | grep -v "node_modules"); do
basename=$(basename "$file" | sed 's/\.[^.]*$//')
imports=$(grep -rn --include="*.ts" --include="*.tsx" "$basename" src/ | grep -v "$file" | wc -l)
if [ "$imports" -eq 0 ]; then
echo "UNUSED: $file"
fi
done
# Find route definitions and verify their components are imported
grep -rn --include="*.tsx" --include="*.ts" -E "path:\s*['\"]|<Route" src/
```
Rules:
- `console.log` and `console.debug` must be removed before shipping (use proper error logging for `console.error`).
- Commented-out code blocks must be removed — version control preserves history.
- `TODO` and `FIXME` comments older than the current sprint should be resolved or converted to tracked issues.
- Unused imports are caught by the linter (Step 1); confirm they are gone.
**Hard gate:** Unreachable pages, entirely unused files, and significant dead code blocks **must** be removed before approval. These are blocking findings.
---
## Step 9 — Verify file and export structure
Every feature area should follow a consistent structure. Check that the app's layout matches this pattern:
```
src/
├── components/ # Shared presentational components
│ └── <name>/
│ ├── <name>.tsx
│ └── index.ts # re-exports the public API
├── hooks/ # Custom hooks (each file = one hook)
├── utils/ # Pure utility functions (no React)
├── contexts/ # React context providers
├── pages/ or views/ # Route-level components
└── types/ # Shared TypeScript types
```
Flag:
- Business logic sitting directly in page components (should be in hooks)
- Utility functions living inside component files (should be in `utils/`)
- Types defined inline in component files when they are used across multiple files (should be in `types/`)
- Missing `index.ts` barrel files for component directories (makes imports verbose)
---
## Step 10 — Report findings
Produce a structured report grouped by category:
| Category | File | Line | Issue | Recommendation |
|----------|------|------|-------|----------------|
| TypeScript | `src/hooks/useData.ts` | 18 | `response as any` cast | Import and use `NodeItem` type from `@cognite/sdk` |
| Size | `src/components/Dashboard.tsx` | — | 340 lines, mixes fetch and render logic | Extract `useDashboardData` hook (~120 lines) |
| DRY | `src/components/A.tsx`, `src/components/B.tsx` | 45, 62 | Identical date formatter | Extract to `src/utils/formatDate.ts` |
| Naming | `src/hooks/data.ts` | — | File name does not start with `use` | Rename to `useData.ts` |
| Dead code | `src/App.tsx` | 88 | `console.log("debug response", data)` | Remove |
If no issues are found in a step, state "No issues found" for that step. Do not skip steps silently.
---
## Done
Summarize the total number of findings by category and list the highest-impact items to address first. Any `any` type and lint error must be treated as blocking — list these separately.
@@ -0,0 +1,364 @@
---
name: correctness-and-error-handling
description: "MUST be used whenever fixing correctness and error handling issues in a Flows app. This skill finds AND fixes bugs, missing error states, unhandled rejections, and edge-case failures — it does not just report them. Triggers: correctness, error handling, bug fix, edge case, crash, unhandled, null, undefined, empty state, loading state, error boundary, try catch, async error, useEffect cleanup, type guard, runtime error, robustness."
allowed-tools: Read, Glob, Grep, Shell, Write
metadata:
argument-hint: "[file or directory to fix, or leave blank to fix the whole app]"
---
# Correctness & Error Handling Fix
Find and fix correctness issues and missing error handling in **$ARGUMENTS** (or the whole app if no argument is given). Work through every step below. Each step searches for problems and then **fixes them in place**. Only report issues that cannot be auto-fixed.
---
## Step 1 — Map data flows and fix known defects
Read these files before checking anything:
- `src/main.tsx` / `src/App.tsx` — top-level error boundaries and auth flow
- All files matching `**/hooks/*.ts`, `**/contexts/*.tsx` — shared async state
- All files matching `**/api/*.ts`, `**/services/*.ts` — CDF SDK call sites
For each async data source, note:
- What happens when the request fails (network error, CDF 403, timeout)?
- What does the UI show while loading?
- What does the UI show if the result is empty?
### Find and fix known defects in critical paths
```bash
# Find TODO/FIXME/HACK in critical code paths (not test files)
grep -rn --include="*.ts" --include="*.tsx" -E "(TODO|FIXME|HACK|XXX):" src/ | grep -v ".test." | grep -v ".spec."
# Find "fix" or "broken" or "workaround" markers
grep -rn --include="*.ts" --include="*.tsx" -i -E "(TODO.*fix|workaround|broken|known.?bug|temporary.?hack)" src/
```
For each match in a critical path (data fetching, rendering, auth, navigation):
1. **Read the surrounding code** to understand the incomplete/broken behavior.
2. **Fix the underlying issue** — implement the missing logic, correct the broken behavior, or add proper error handling.
3. If the fix requires significant architectural changes beyond this skill's scope, **replace the TODO with a safe failure mode**: graceful error handling, a sensible fallback value, or an explicit user-facing message explaining degraded functionality.
4. **Remove the TODO/FIXME/HACK comment** after fixing. The code should speak for itself.
Do not leave TODOs in critical paths. Every one must be resolved or converted to a safe fallback.
---
## Step 2 — Add top-level error boundary
Every Flows app must have at least one React Error Boundary wrapping the main content so that an unexpected render-time exception shows a user-friendly message instead of a blank screen.
```bash
grep -rn --include="*.tsx" --include="*.ts" -E "ErrorBoundary|componentDidCatch|getDerivedStateFromError" src/
```
If no error boundary exists, **create the ErrorFallback component and add the ErrorBoundary wrapper** to `App.tsx`. Install `react-error-boundary` if not present:
```bash
pnpm add react-error-boundary
```
Then add to `App.tsx`:
```tsx
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error }: { error: Error }) {
return (
<div role="alert" className="p-8 text-center">
<p className="text-lg font-semibold">Something went wrong</p>
<pre className="mt-2 text-sm text-muted-foreground">{error.message}</pre>
</div>
);
}
// Wrap the main content:
<ErrorBoundary FallbackComponent={ErrorFallback}>
<MainContent />
</ErrorBoundary>
```
Write the updated `App.tsx` with the ErrorBoundary in place. Do not just suggest it — make the edit.
---
## Step 3 — Wrap unhandled async functions in try/catch
Search for every `async` function and `Promise` chain that does not have error handling:
```bash
# Find async functions
grep -rn --include="*.ts" --include="*.tsx" -E "async\s+function|async\s+\(" src/
# Find .then() without .catch()
grep -rn --include="*.ts" --include="*.tsx" -E "\.then\(" src/ | grep -v "\.catch\("
```
**Fix each one:**
- For bare `async` functions that lack try/catch: **wrap the function body** in try/catch. Log the error with context and re-throw so callers/query layers can handle it:
```ts
async function fetchAssets(sdk: CogniteClient) {
try {
const result = await sdk.assets.list({ limit: 100 });
return result.items;
} catch (error) {
console.error("Failed to fetch assets:", error);
throw error;
}
}
```
- For `.then()` without `.catch()`: **add `.catch()`** to the chain:
```ts
somePromise.then(handleResult).catch((error) => {
console.error("Operation failed:", error);
});
```
- For TanStack Query consumers (`useQuery`/`useMutation`) missing `isError` handling: **add the error check and error UI** to the component:
```tsx
const { data, isLoading, isError, error } = useQuery({
queryKey: ["assets"],
queryFn: () => fetchAssets(sdk),
});
if (isError) return <ErrorMessage error={error} />;
```
Read each file, make the edit, and write it back.
---
## Step 4 — Add missing loading, error, and empty states to components
For each component that fetches data, it must have three distinct UI states:
| State | Required UI |
|-------|-------------|
| Loading | Spinner, skeleton, or loading indicator |
| Error | User-readable message (not a raw error object or blank space) |
| Empty | "No results" / "Nothing here yet" message (not a blank list) |
Search for components that render data without checking loading state:
```bash
grep -rn --include="*.tsx" -E "\.(map|filter|find)\(" src/ | grep -v "isLoading\|isPending\|skeleton\|Skeleton"
```
For each hit, read the component and **add the missing states directly**:
- **Missing loading state** — add before the data render:
```tsx
if (isLoading) {
return <div className="flex items-center justify-center p-8"><Spinner /></div>;
}
```
- **Missing error state** — add after the loading check:
```tsx
if (isError) {
return (
<div role="alert" className="p-4 text-center text-destructive">
<p>Failed to load data. Please try again.</p>
</div>
);
}
```
- **Missing empty state** — add after the error check, before the `.map()`:
```tsx
if (!data || data.length === 0) {
return (
<div className="p-8 text-center text-muted-foreground">
<p>No results found.</p>
</div>
);
}
```
Insert these checks in the correct order (loading, then error, then empty) above the existing data render. Write each fixed file.
---
## Step 5 — Add type narrowing for external data
External data (CDF responses, URL params, `localStorage`, `JSON.parse`) must be validated before use. TypeScript types alone are not runtime guarantees.
```bash
# Find JSON.parse without validation
grep -rn --include="*.ts" --include="*.tsx" -E "JSON\.parse\(" src/
# Find localStorage reads
grep -rn --include="*.ts" --include="*.tsx" -E "localStorage\.(get|set)Item" src/
# Find useSearchParams usage
grep -rn --include="*.ts" --include="*.tsx" -E "useSearchParams|searchParams\.get" src/
```
**Fix each one:**
- **`JSON.parse(x) as T`** — replace with Zod safeParse:
```ts
import { z } from "zod";
const MySchema = z.object({ /* fields */ });
const parseResult = MySchema.safeParse(JSON.parse(raw));
if (!parseResult.success) {
console.warn("Invalid stored data, using defaults:", parseResult.error);
return defaultValue;
}
const validated = parseResult.data;
```
- **`searchParams.get("id")`** without null check — add nullish fallback:
```ts
const id = searchParams.get("id") ?? defaultId;
```
- **`localStorage.getItem(key)`** used directly — add type guard and fallback:
```ts
const raw = localStorage.getItem(key);
if (raw === null) return defaultValue;
try {
const parsed = JSON.parse(raw);
// validate parsed shape
return isValidShape(parsed) ? parsed : defaultValue;
} catch {
return defaultValue;
}
```
Do not cast external data with `as MyType` — that bypasses runtime safety. Read, fix, and write each file.
---
## Step 6 — Fix null, undefined, and unsafe array access
Read every component that accesses properties of data returned from CDF or passed via props.
```bash
grep -rn --include="*.tsx" --include="*.ts" -E "\w+\[0\]\." src/
```
**Fix each unsafe pattern found:**
- **Unsafe nested property access** — add optional chaining and nullish coalescing:
```tsx
// Before: asset.properties.space.Asset.name
// After:
const name = asset.properties?.["my-space"]?.["Asset"]?.name ?? "Unknown";
```
- **Unguarded `.map()` on possibly-undefined array** — add nullish fallback:
```tsx
// Before: items.map(renderItem)
// After:
(items ?? []).map(renderItem)
```
- **Unsafe array index access** — use `.at()` with optional chaining:
```tsx
// Before: items[0].name
// After:
const first = items.at(0)?.name ?? "—";
```
Read each file with a match, apply the fix, and write it back.
---
## Step 7 — Add useEffect cleanup functions
Every `useEffect` that sets up a subscription, timer, event listener, or async operation that can outlive the component must return a cleanup function.
```bash
grep -rn --include="*.tsx" --include="*.ts" -B 2 -A 15 "useEffect" src/
```
For each `useEffect`, check whether cleanup is needed and **add the cleanup function** if missing:
| Pattern | Fix to add |
|---------|-----------|
| `addEventListener` | Add `return () => removeEventListener(...)` |
| `setInterval` / `setTimeout` | Add `return () => clearInterval(id)` / `clearTimeout(id)` |
| CDF streaming / SSE | Add `return () => stream.close()` |
| `fetch` / CDF SDK call | Add AbortController: `const controller = new AbortController()` at the top, pass `controller.signal` to fetch, add `return () => controller.abort()`, and guard state updates with `if (!controller.signal.aborted)` |
| Zustand / event emitter subscription | Add `return () => unsubscribe()` |
Reference pattern for async effects:
```ts
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const data = await fetchWithSignal(controller.signal);
if (!controller.signal.aborted) setState(data);
} catch (err) {
if (err instanceof Error && err.name !== "AbortError") {
setError(err);
}
}
}
load();
return () => controller.abort();
}, [id]);
```
Read each effect, add the missing cleanup, and write the file.
---
## Step 8 — Add edge case guards
For each feature, check and **add guards** for:
- **Empty data**: If zero-item lists are not handled, add an empty state check before rendering.
- **Single item**: If list rendering has off-by-one bugs with a single entry, fix the logic.
- **Maximum data / pagination**: If CDF returns the full `limit` and there are more pages, ensure pagination is communicated to the user. Add a "Load more" or pagination indicator if missing.
- **Concurrent requests / stale results**: If the user can trigger a new request before the previous completes, add stale request cancellation (AbortController or a request ID check).
- **Network offline**: If the app silently fails when offline, add a meaningful error message.
For Atlas tool `execute` functions, **add argument validation** at the top of every execute function:
```ts
execute: async (args) => {
if (!args.assetId || typeof args.assetId !== "string") {
return { output: "Missing or invalid assetId", details: null };
}
// ... safe to proceed
}
```
Search for `execute` functions, read each one, add the validation, and write the file.
---
## Step 9 — Report remaining findings
Produce a structured report covering:
1. **What was fixed in each step** — summarize the changes made (files edited, patterns fixed).
2. **Remaining issues** — only list issues that could not be auto-fixed (e.g., require architectural changes, need product decisions, or are outside the scope of this skill).
| Severity | File | Line | Issue | Status |
|----------|------|------|-------|--------|
| HIGH | `src/hooks/useAssets.ts` | 34 | Unhandled promise rejection | FIXED — wrapped in try/catch |
| MEDIUM | `src/components/AssetList.tsx` | 12 | No empty state | FIXED — added empty state check |
| MEDIUM | `src/auth/flow.ts` | 45 | Auth error handling needs product decision | UNFIXED — requires team input |
If no issues are found in a step, state "No issues found" for that step. Do not skip steps silently.
---
## Done
Summarize what was fixed by severity. Flag any remaining HIGH issues that could cause data loss, crashes in production, or misleading UI states, and list them first for immediate attention.
+122
View File
@@ -0,0 +1,122 @@
---
name: create-client-tool
description: "MUST be used whenever creating an AtlasTool (client-side tool) for an Atlas agent. Do NOT manually write AtlasTool definitions or wire them into useAtlasChat — this skill handles the TypeBox schema, execute function, and hook wiring. Prerequisite: integrate-atlas-chat (vendored src/atlas-agent + TypeBox/AJV deps). This includes tools that fetch data, render UI, call APIs, show charts, query local state, or perform any browser-side action. Triggers: AtlasTool, client tool, add tool, create tool, new tool, tool definition, agent tool."
allowed-tools: Read, Glob, Grep, Edit, Write
metadata:
argument-hint: "[tool-name] [brief description of what it does]"
---
# Create a Client Tool
Scaffold a new `AtlasTool` named **$ARGUMENTS** and wire it into the app.
## Prerequisite
**`integrate-atlas-chat`** must already be complete: the app should vend the atlas-agent sources under `src/atlas-agent/` (including `react.ts`) and have `@sinclair/typebox`, `ajv`, and `ajv-formats` installed as in that skill.
## Background
Client tools let the Atlas Agent invoke logic that runs in the browser — rendering charts,
querying local state, showing UI panels, triggering navigation, etc. The agent decides when
to call the tool; the app executes it and returns a result.
The flow is:
1. Agent responds with a `clientTool` action
2. The library validates the arguments against the TypeBox schema
3. `execute()` runs in the browser and returns `{ output, details }`
4. `output` (string) is sent back to the agent as the tool result
5. `details` (any shape) is available on `message.toolCalls` for the UI to render
---
## Step 1 — Understand the codebase
Before writing anything, read:
- The file where `useAtlasChat` is called (often `src/App.tsx` or a chat hook) to find where `tools` is passed — imports are typically from `./atlas-agent/react` after **`integrate-atlas-chat`**
- Any existing tool definitions to match the file/naming conventions
---
## Step 2 — Define the tool
Create the tool as a typed constant. Use `Type` from `@sinclair/typebox` to define the parameters schema — this gives both compile-time types and runtime validation (same stack as the vendored atlas-agent from **`integrate-atlas-chat`**).
```ts
import { Type } from "@sinclair/typebox";
import type { AtlasTool } from "./atlas-agent/types";
export const myTool: AtlasTool = {
name: "my_tool", // snake_case — this is what the agent uses to invoke it
description:
"One sentence describing what this tool does and when the agent should call it.",
parameters: Type.Object({
exampleParam: Type.String({ description: "What this param is for" }),
optionalNum: Type.Optional(Type.Number({ description: "..." })),
}),
execute: async (args) => {
// args is fully typed from the schema above
// Do the work here — call APIs, update state, render UI, etc.
return {
output: "Plain text summary sent back to the agent",
details: {
// Any structured data you want available in the UI via message.toolCalls
},
};
},
};
```
Adjust the `./atlas-agent/...` path if the tool file is not directly under `src/` next to the `atlas-agent` folder (for example `../atlas-agent/types` from `src/tools/`).
### TypeBox quick reference
| Schema | Usage |
|---|---|
| `Type.String()` | string |
| `Type.Number()` | number |
| `Type.Boolean()` | boolean |
| `Type.Literal("foo")` | exact value |
| `Type.Union([Type.Literal("a"), Type.Literal("b")])` | enum |
| `Type.Array(Type.String())` | string[] |
| `Type.Object({ ... })` | object |
| `Type.Optional(...)` | mark any field optional |
Always add a `description` to each field — the agent uses these to understand what to pass.
---
## Step 3 — Wire into useAtlasChat
Find the `useAtlasChat` call and add the tool to the `tools` array:
```ts
const { messages, send, ... } = useAtlasChat({
client: isLoading ? null : sdk,
agentExternalId: AGENT_EXTERNAL_ID,
tools: [myTool], // add here
});
```
---
## Step 4 — Render tool results (if needed)
If the tool returns structured `details`, render them in the message list.
`message.toolCalls` is a `ToolCall[]` — one entry per tool call (client-side and server-side) in call order.
```tsx
{msg.toolCalls?.map((tc, i) => (
// tc.name — tool name
// tc.output — the string sent back to the agent
// tc.details — your structured data (cast to your known shape)
<MyToolOutput key={i} data={tc.details as MyToolDetails} />
))}
```
---
## Done
The agent can now invoke `$ARGUMENTS`. Describe what it does clearly in the `description`
field — the agent relies on that string to decide when and how to call the tool.
+306
View File
@@ -0,0 +1,306 @@
---
name: dependencies-audit
description: "MUST be used whenever fixing dependency issues in a Flows app. This skill finds AND fixes vulnerabilities, outdated packages, deprecated dependencies, and license issues — it does not just report them. Triggers: dependencies, packages, fix dependencies, update packages, fix vulnerabilities, npm audit fix, pnpm audit fix, CVE fix, outdated, deprecated, supply chain, license."
allowed-tools: Read, Glob, Grep, Shell, Write
metadata:
argument-hint: "[path to package.json, or leave blank to audit the root package.json]"
---
# Dependencies Fix
Find and fix all dependency issues in **$ARGUMENTS** (or the root `package.json` if no argument is given) — vulnerabilities, outdated packages, deprecated dependencies, license problems, and supply-chain risks. This skill produces the `review-packages.md` artifact required by the Flows app review process.
---
## Step 1 — Read and list all dependencies
```bash
# List all dependencies and devDependencies
node -e "
const pkg = require('./package.json');
console.log('=== Dependencies ===');
Object.entries(pkg.dependencies || {}).forEach(([name, ver]) => console.log(name + ' @ ' + ver));
console.log('\\n=== Dev Dependencies ===');
Object.entries(pkg.devDependencies || {}).forEach(([name, ver]) => console.log(name + ' @ ' + ver));
"
```
Record the total count of dependencies and devDependencies.
---
## Step 2 — Look up npm metadata and update outdated packages
For each package, gather:
- **Latest version** on npm
- **Weekly downloads**
- **Last publish date**
- **Deprecated** flag
```bash
# Batch lookup — run for each package (example for a single package)
npm view <package-name> --json 2>/dev/null | node -e "
const data = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
console.log(JSON.stringify({
name: data.name,
latest: data['dist-tags']?.latest,
modified: data.time?.modified,
deprecated: data.deprecated || false,
}));
"
# For weekly downloads, use the npm API
curl -s "https://api.npmjs.org/downloads/point/last-week/<package-name>" | node -e "
const data = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
console.log(data.downloads);
"
```
For efficiency, batch multiple lookups. If the project has many dependencies, use a script:
```bash
node -e "
const { execSync } = require('child_process');
const pkg = require('./package.json');
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
for (const [name, usedVersion] of Object.entries(allDeps)) {
try {
const info = JSON.parse(execSync('npm view ' + name + ' --json 2>/dev/null', { encoding: 'utf8' }));
const latest = info['dist-tags']?.latest || 'unknown';
const modified = info.time?.modified || 'unknown';
const deprecated = info.deprecated ? 'YES' : 'No';
console.log([name, usedVersion, latest, modified, deprecated].join(' | '));
} catch {
console.log(name + ' | ' + usedVersion + ' | LOOKUP FAILED');
}
}
"
```
### Fix: Update outdated packages
For each package that is >1 major version behind, update it:
```bash
pnpm update <package>@latest
```
For packages that are 1+ minor versions behind, update to latest minor:
```bash
pnpm update <package>
```
After updating, run `pnpm install` and `pnpm run build` to verify nothing breaks. If a major update breaks the build, revert that specific update and note it as a manual-fix item.
---
## Step 3 — Run security audit and fix vulnerabilities
```bash
# Run audit with the project's package manager
pnpm audit --json 2>/dev/null || npm audit --json 2>/dev/null
# Also run production-only audit (what ships to users)
pnpm audit --prod --json 2>/dev/null || npm audit --production --json 2>/dev/null
```
Parse the JSON output for:
- Severity counts (critical, high, moderate, low)
- Per-vulnerability details (package, severity, title, patched version, advisory URL)
Any package with a known CVE is an automatic **Fail** in the health column.
### Fix: Resolve vulnerabilities
Run `pnpm audit fix` to auto-fix what's possible. For remaining high/critical CVEs that can't be auto-fixed, manually update the vulnerable package in `package.json` to the patched version and run `pnpm install`. If the patched version has breaking changes, apply the minimum code changes needed to adapt. If a vulnerability is in a transitive dependency, use `pnpm overrides` in `package.json` to force the patched version:
```json
{
"pnpm": {
"overrides": {
"vulnerable-package": ">=2.1.0"
}
}
}
```
After applying fixes, re-run `pnpm audit` to confirm the vulnerabilities are resolved. Run `pnpm run build` to verify nothing breaks.
---
## Step 4 — Assign health scores and fix Fail-scored packages
For each package, assign a health indicator:
| Health | Criteria |
|--------|----------|
| **Pass** | >100k weekly downloads AND updated within last 12 months AND not deprecated AND version is current or near-current (within 1 major) |
| **Warn** | 10k100k weekly downloads OR >12 months since last publish OR >1 major version behind |
| **Fail** | <10k weekly downloads OR no update in 2+ years OR deprecated OR known CVE |
Edge cases:
- `@cognite/*` packages: trust Cognite-internal packages even if download counts are low
- `@types/*` packages: trust DefinitelyTyped packages; focus on whether the version matches the main package
- Newly published packages (<6 months old): flag as **Warn** for review, not auto-Fail on low downloads
### Fix: Replace Fail-scored packages
For each Fail-scored package:
- **If deprecated:** find and install the recommended replacement. Update all imports across the codebase.
- **If unmaintained (2+ years):** find an actively maintained alternative with equivalent functionality. Replace it.
- **If low downloads and not `@cognite/*`:** evaluate whether it's truly needed. If a native JS/TS equivalent exists or the functionality is simple, remove the dependency and implement inline.
After each replacement, run `pnpm install` and `pnpm run build` to verify the replacement works.
---
## Step 5 — Check for supply-chain risks and mitigate
```bash
# Check for install scripts (preinstall, postinstall, prepare)
node -e "
const { execSync } = require('child_process');
const pkg = require('./package.json');
const allDeps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
for (const name of allDeps) {
try {
const info = JSON.parse(execSync('npm view ' + name + ' --json 2>/dev/null', { encoding: 'utf8' }));
const scripts = info.scripts || {};
const risky = ['preinstall', 'install', 'postinstall'].filter(s => scripts[s]);
if (risky.length > 0) {
console.log('INSTALL SCRIPT: ' + name + ' — ' + risky.join(', '));
}
} catch {}
}
"
# Check for packages with very few maintainers (single point of failure)
# This is informational, not blocking
```
### Fix: Evaluate and mitigate install script risks
For each dependency with install scripts, determine if the script is legitimate (e.g., native module compilation for `sharp`, `esbuild`, `better-sqlite3`). Known build tools and native module packages are expected to have install scripts.
If the package is not a known build tool and has suspicious install scripts, replace it with a safer alternative. After replacement, run `pnpm install` and `pnpm run build` to verify.
---
## Step 6 — Check license compatibility and replace problematic packages
```bash
# List all licenses
npx license-checker --summary 2>/dev/null || node -e "
const { execSync } = require('child_process');
const pkg = require('./package.json');
const allDeps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
for (const name of allDeps) {
try {
const info = JSON.parse(execSync('npm view ' + name + ' --json 2>/dev/null', { encoding: 'utf8' }));
console.log(name + ': ' + (info.license || 'UNKNOWN'));
} catch {}
}
"
```
Acceptable licenses for Flows apps (commercial distribution):
- MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD, Unlicense, CC0-1.0
Licenses that need legal review:
- GPL-2.0, GPL-3.0, LGPL-2.1, LGPL-3.0, AGPL-3.0, MPL-2.0, EUPL-1.1
- Any "UNKNOWN" or missing license
### Fix: Replace packages with problematic licenses
For each package with a copyleft license (GPL, AGPL) or unknown license in **production dependencies**, find an MIT/Apache-2.0 licensed alternative and replace it. Update all imports across the codebase.
For **devDependencies** with copyleft licenses, these are lower risk but still flag for awareness.
After each replacement, run `pnpm install` and `pnpm run build` to verify.
---
## Step 7 — Generate the review-packages.md artifact (post-fix state)
Re-run the metadata lookups after all fixes have been applied to capture the post-fix state. Then produce the output in the format required by the Flows app review process:
```markdown
## Package audit: [app name]
### Dependencies
| Package | Used version | Latest | Weekly downloads | Last published | Deprecated | CVEs | Health |
| ------- | ------------ | ------ | ---------------- | -------------- | ---------- | ---- | ------ |
| react | ^18.2.0 | 18.3.1 | 25M | 2024-04-26 | No | 0 | Pass |
| some-old-lib | ^1.0.0 | 1.0.3 | 5k | 2021-03-15 | No | 0 | Fail |
### Dev Dependencies
| Package | Used version | Latest | Weekly downloads | Last published | Deprecated | CVEs | Health |
| ------- | ------------ | ------ | ---------------- | -------------- | ---------- | ---- | ------ |
| vitest | ^1.6.0 | 2.0.1 | 8M | 2024-07-01 | No | 0 | Pass |
### Security audit
| Severity | Count |
| -------- | ----- |
| Critical | 0 |
| High | 0 |
| Moderate | 0 |
| Low | 0 |
#### Vulnerabilities
| Package | Severity | Title | Patched in | Advisory |
| ------- | -------- | ----- | ---------- | -------- |
| (none found) | — | — | — | — |
### License summary
| License | Count | Packages |
| ------- | ----- | -------- |
| MIT | 45 | react, react-dom, ... |
| Apache-2.0 | 3 | ... |
### Supply-chain flags
| Package | Risk | Details |
| ------- | ---- | ------- |
| (none found) | — | — |
```
---
## Step 8 — Report remaining issues
Summarize what was fixed and what remains:
### Fixed
| Category | Count | Details |
|----------|-------|---------|
| Packages updated | N | list of packages and version changes |
| CVEs resolved | N | list of CVEs fixed |
| Deprecated deps replaced | N | old package -> new package |
| License issues resolved | N | old package -> new package |
### Remaining (could not auto-fix)
List only issues that could not be automatically fixed:
- Breaking changes from major updates that need manual code adaptation
- Licenses that need legal review (e.g., LGPL in transitive dependencies)
- Packages with no maintained alternative available
- Vulnerabilities with no patched version available yet
For each remaining item, explain why it could not be auto-fixed and what the app author needs to do.
---
## Done
State the overall health verdict: how many Pass/Warn/Fail after fixes, how many issues were resolved, and any remaining items that need manual attention from the app author.
+52
View File
@@ -0,0 +1,52 @@
---
name: design
description: Simplified Aura guidance for selecting primitives, keeping token usage consistent, and applying reliable layout/copy/state patterns in Flows and Fusion apps.
allowed-tools: Read, Glob, Grep, Edit, Write
---
## Role
Use Aura as the default UI system for customer-facing product work. Prefer decision-level guidance over exhaustive rules:
- choose the right primitive first,
- apply semantic tokens (no raw values),
- keep layouts and UX states consistent,
- write concise, action-oriented copy.
Use Storybook for component APIs and exact props. Use this skill for "what to choose and when."
<when-to-reference>
Consult this skill whenever you are:
- Creating or migrating interactive UI, forms, tables, navigation, or data display
- Writing or modifying styles, colors, spacing, or typography
- Choosing components, tokens, or layout patterns
- Creating or restructuring pages and responsive layouts
- Writing or editing any user-facing text
- Building forms, handling API responses, async actions, confirmations, or dynamic content
- Implementing accessibility (keyboard, focus, headings, ARIA, alt text)
- Applying Aura correctly in a Flows or React app
</when-to-reference>
<file-routing>
| If you are… | Open |
|-------------|------|
| Choosing primitives and deciding what to use when | `primitive-usage.md` |
| Where to look for Storybook, docs, and Figma (router) | `picking-components.md` |
| Structuring a page or choosing a layout pattern | `building-pages.md` |
| Writing any user-facing text | `writing-copy.md` |
| Forms, loading, errors, confirmations, or page-level accessibility | `handling-states.md` |
| Looking up Storybook URLs for foundations or components | `storybook-links.md` |
</file-routing>
## Operating principles
1. Use Aura primitives before custom UI.
2. Follow foundations through semantic tokens and Aura defaults; do not hardcode raw values.
3. If a primitive almost fits, do not override visuals to force it; check variants/props first, then document the gap.
4. Keep behavior predictable and accessible: keyboard support, visible focus, and clear feedback for loading/success/error.
5. Use `storybook-links.md` for canonical component/foundation URLs.
6. Use publicly reachable links — Aura design system docs (Mintlify), Fusion preview Storybook, and Figma as documented in `primitive-usage.md` and `picking-components.md`.
+354
View File
@@ -0,0 +1,354 @@
# Building pages and layouts
## Role
You are structuring pages for a customer-facing application. Consistent layouts across apps are essential. Every page must use an approved pattern.
The Aura system uses Tailwind CSS for layout. All layouts use Tailwind flex/grid utilities with Aura spacing tokens. The sidebar component uses Aura's sidebar tokens (bg-sidebar, text-sidebar-foreground, etc.).
For all Storybook URLs, see `./storybook-links.md`.
<storybook-foundation>
Source of truth for layout foundations:
https://cognitedata.github.io/aura/storybook/?path=/docs/foundations-layout--docs
Cross-reference these Storybook stories when implementing any layout pattern (full URLs):
| Story | URL | Use for |
|-------|-----|---------|
| Breakpoints | https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--breakpoints | Official breakpoint values |
| Container Queries | https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--container-queries | Responsive within components |
| Column Spans | https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--column-spans | 2-col, 3-col, asymmetric splits |
| Layout Compositions | https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--compositions | Combining layout parts |
| Sidebar Left | https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--sidebar-left-layout | Sidebar implementation |
| Card Grid | https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--card-grid-layout | Card grid layout |
| Dashboard | https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--dashboard-layout | Dashboard with metrics |
| Comprehensive Dashboard | https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--comprehensive-dashboard | Full dashboard |
| Grid Patterns | https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--grid-patterns-reference | Grid configuration catalog |
| Code Examples | https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--code-examples | Copy-paste Tailwind code |
Base: https://cognitedata.github.io/aura/storybook/
</storybook-foundation>
<foundations>
Standard layout primitives used across all patterns:
CONTENT MAX-WIDTHS:
- max-w-7xl — dashboards, full-width layouts
- max-w-4xl — detail pages
- max-w-2xl — forms, wizard step content
- max-w-sm — search inputs, narrow controls
SECTION SPACING:
- space-y-8 — between major page sections (e.g. form groups)
- space-y-6 — between sections within a page
- space-y-4 — between items within a section
- space-y-2 — between label and field, tight groupings
GRID GAPS:
- gap-6 — dashboard grids, chart grids, panel gaps
- gap-4 — card grids, metric grids
- gap-3 — toolbar items, button groups
PAGE PADDING:
- px-6 py-8 — standard content area (desktop)
- px-4 py-6 — mobile content area
- p-4 — card/panel internal padding
- p-6 — larger card internal padding
</foundations>
<sidebar-tokens>
The Aura system has dedicated sidebar tokens that differ from the main content area:
| Token | Purpose | Light value | Dark value |
|-------|---------|-------------|------------|
| bg-sidebar | Sidebar background | mountain-900 | mountain-900 |
| text-sidebar-foreground | Sidebar text | mountain-100 | mountain-100 |
| text-sidebar-primary | Sidebar primary | mountain-600 | mountain-600 |
| text-sidebar-primary-foreground | Active item text | white | white |
| bg-sidebar-accent | Active/hover bg | mountain-700 | mountain-700 |
| text-sidebar-accent-foreground | Active text | white | white |
| border-sidebar-border | Sidebar borders | mountain-800 | mountain-800 |
Note: The sidebar is ALWAYS dark-themed, even in light mode.
</sidebar-tokens>
<patterns>
<pattern name="sidebar-content">
<use-when>
3+ top-level sections. Persistent navigation needed.
Most common for multi-page apps.
</use-when>
<structure>
┌──────────┬─────────────────────────────┐
│ │ Page Header / Breadcrumb │
│ Sidebar │─────────────────────────────│
│ Nav │ │
│ (dark) │ Main Content Area │
│ │ (bg-background) │
│ │ │
└──────────┴─────────────────────────────┘
</structure>
<responsive-behavior>
Desktop (1440px+): Sidebar 240px, content fills rest.
Tablet (768px-1439px): Sidebar collapsible via hamburger.
Mobile (below 768px): Sidebar hidden. Hamburger menu.
Consider bottom nav for 3-5 primary sections.
</responsive-behavior>
<storybook-reference>
Implement using Storybook **Example: Sidebar Left**:
https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--sidebar-left-layout
</storybook-reference>
</pattern>
<pattern name="full-width-dashboard">
<use-when>
Data visualizations, metrics, monitoring. Maximum horizontal space needed.
</use-when>
<structure>
┌─────────────────────────────────────────┐
│ Top Navigation Bar │
├─────────────────────────────────────────┤
│ Page Header + Filters │
├─────────────────────────────────────────┤
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │Metric │ │Metric │ │Metric │ │
│ └───────┘ └───────┘ └───────┘ │
├─────────────────────────────────────────┤
│ Charts / Visualizations │
├─────────────────────────────────────────┤
│ Data Table │
└─────────────────────────────────────────┘
</structure>
<responsive-behavior>
Desktop: Multi-column grid (grid-cols-3 or grid-cols-4).
Tablet: 2-column grid. Charts stack.
Mobile: Single column. Metrics as horizontal scroll.
</responsive-behavior>
<storybook-reference>
Implement using Storybook **Example: Dashboard** and **Example: Comprehensive Dashboard**:
- https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--dashboard-layout
- https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--comprehensive-dashboard
</storybook-reference>
</pattern>
<pattern name="form-page">
<use-when>
Data entry, creation flows, configuration, settings with form fields.
</use-when>
<structure>
┌─────────────────────────────────────────┐
│ Page Header + Back navigation │
├─────────────────────────────────────────┤
│ ┌───────────────────────────────┐ │
│ │ Form Section 1 (heading) │ │
│ │ [fields] │ │
│ ├───────────────────────────────┤ │
│ │ Form Section 2 (heading) │ │
│ │ [fields] │ │
│ └───────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Sticky footer: [Cancel] [Save action] │
└─────────────────────────────────────────┘
</structure>
<responsive-behavior>
Desktop: Form centered, max-w-2xl (672px) or max-w-3xl.
Tablet: Form fills width with px-6 padding.
Mobile: Full width. Sticky footer stays. Fields stack.
</responsive-behavior>
<storybook-reference>
Use centered content (max-w-2xl) and Tailwind patterns from Storybook **Code Examples**:
https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--code-examples
</storybook-reference>
</pattern>
<pattern name="detail-page">
<use-when>
Viewing a single record: report details, user profile, item information with related data.
</use-when>
<structure>
┌─────────────────────────────────────────┐
│ Breadcrumb: Reports > Q2 Summary │
├─────────────────────────────────────────┤
│ Record Header [Title, status, actions] │
├─────────────────────────────────────────┤
│ ┌─────────────────┬───────────────┐ │
│ │ Main Content │ Sidebar │ │
│ │ (2/3 width) │ (1/3 width) │ │
│ └─────────────────┴───────────────┘ │
└─────────────────────────────────────────┘
</structure>
<responsive-behavior>
Desktop: Two-column (grid-cols-3, main span-2, sidebar span-1).
Tablet: Sidebar below main content.
Mobile: Single column. Sidebar collapses.
</responsive-behavior>
<storybook-reference>
Use asymmetric columns from Storybook **Pattern: Column Spans** and composition patterns from **Code Examples**:
- https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--column-spans
- https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--code-examples
</storybook-reference>
</pattern>
<pattern name="settings-page">
<use-when>
App preferences, account settings, notification config.
</use-when>
<structure>
┌─────────────────────────────────────────┐
│ Page Header: Settings │
├───────────┬─────────────────────────────┤
│ Settings │ Section Content │
│ Nav │ [Form fields / toggles] │
└───────────┴─────────────────────────────┘
</structure>
<responsive-behavior>
Desktop: Left nav + content area.
Tablet: Top tabs replacing left nav.
Mobile: Category list → tap opens section full-screen.
</responsive-behavior>
<storybook-reference>
Implement using Storybook **Pattern: Layout Compositions**:
https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--compositions
</storybook-reference>
</pattern>
<pattern name="split-screen">
<use-when>
Comparison views, editor + preview, master-detail with equal emphasis on both sides.
</use-when>
<structure>
┌─────────────────────┬─────────────────────┐
│ │ │
│ Panel Left │ Panel Right │
│ (1/2 width) │ (1/2 width) │
│ │ │
└─────────────────────┴─────────────────────┘
</structure>
<responsive-behavior>
Desktop: grid-cols-2, equal columns.
Tablet: grid-cols-2 with narrower gap.
Mobile: Stack vertically (grid-cols-1), or use Segmented Control to switch between panels.
</responsive-behavior>
<storybook-reference>
Implement using Storybook **Pattern: Column Spans**:
https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--column-spans
</storybook-reference>
</pattern>
<pattern name="three-panel">
<use-when>
Navigation + content + properties panel. IDE-style layouts. Complex editing workflows with context panels.
</use-when>
<structure>
┌──────────┬───────────────────┬──────────┐
│ │ │ │
│ Nav/ │ Main Content │ Props/ │
│ Tree │ (flexible) │ Detail │
│ (fixed) │ │ (fixed) │
│ │ │ │
└──────────┴───────────────────┴──────────┘
</structure>
<responsive-behavior>
Desktop (1440px+): All 3 panels visible.
Tablet (768-1439px): Hide right panel, toggle via button.
Mobile (below 768px): Single panel with navigation as Drawer, right panel as bottom sheet or separate route.
</responsive-behavior>
<storybook-reference>
Implement using Storybook **Pattern: Layout Compositions**:
https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--compositions
</storybook-reference>
</pattern>
<pattern name="list-page">
<use-when>
Browsing collections — reports, users, assets, items. The most common page type in data-heavy applications.
</use-when>
<structure>
┌──────────────────────────────────────────┐
│ Page Header [Title] [Create button] │
├──────────────────────────────────────────┤
│ Filters toolbar [Search] [Filters] │
├──────────────────────────────────────────┤
│ Table / List │
│ (with empty state when no data) │
├──────────────────────────────────────────┤
│ Pagination │
└──────────────────────────────────────────┘
</structure>
<responsive-behavior>
Desktop: Full table with all columns visible.
Tablet: Hide non-essential columns, allow horizontal scroll.
Mobile: Switch to card/list view with stackable filters.
</responsive-behavior>
<storybook-reference>
Use Storybook **Example: Card Grid** for card-style list variants and **Grid Patterns** for table/grid configuration:
- https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--card-grid-layout
- https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--grid-patterns-reference
</storybook-reference>
</pattern>
<pattern name="wizard">
<use-when>
Multi-step creation flows, onboarding, configuration wizards, setup processes.
</use-when>
<structure>
┌──────────────────────────────────────────┐
│ Step indicator (1 — 2 — 3 — 4) │
├──────────────────────────────────────────┤
│ │
│ Step Content Area │
│ (centered, max-w-2xl) │
│ │
├──────────────────────────────────────────┤
│ [Back] [Next/Submit] │
└──────────────────────────────────────────┘
</structure>
<responsive-behavior>
Desktop: Centered content, horizontal numbered step indicator.
Tablet: Same layout with px-6 padding.
Mobile: Step indicator becomes compact ("Step 2 of 4"), content fills width.
</responsive-behavior>
<storybook-reference>
Center step content with max-w-2xl; use Storybook **Code Examples** for step and footer patterns:
https://cognitedata.github.io/aura/storybook/?path=/story/foundations-layout--code-examples
</storybook-reference>
</pattern>
</patterns>
<edge-cases>
1. Only 1-2 pages? — Sidebar still works, or use top nav.
2. Layout not listed? — Compose from these patterns. Add a short code comment if the composition is genuinely new.
3. Both data display and entry? — Choose by primary purpose.
4. Need resizable panels? — Start with split-screen or three-panel, add resize handles as needed.
5. Very wide content (data tables)? — Use full-width-dashboard without max-w constraint, or list-page with horizontal scroll.
</edge-cases>
+426
View File
@@ -0,0 +1,426 @@
# Handling states, validation, and accessibility
## Role
You are implementing how the application responds to user actions and ensuring page-level accessibility. These patterns determine whether users trust the application. Follow them for every form, API call, and user action.
Aura components handle many accessibility concerns automatically; you are responsible for composition, copy, focus, and page structure. Aura's focus system uses `shadow-focus-ring` (custom shadow token). Never remove or override it.
All UI elements use Aura components and tokens. Error states use the destructive token family, warnings use warning tokens, success uses success tokens.
For message wording patterns, see `writing-copy.md`.
For all Storybook URLs, see `./storybook-links.md`.
<aura-coverage priority="high">
What Aura components handle automatically:
| Concern | Aura handles | You verify |
|---------|-------------|-----------|
| Focus indicators | shadow-focus-ring on interactive elements | Not hidden by overflow or z-index |
| Keyboard activation | Button: Enter/Space. Input: standard keys | Custom elements also respond |
| ARIA roles | Correct roles on Dialog, Segmented Control (Tabs ARIA pattern), etc. | Custom components have roles |
| Color contrast | Token pairs designed for AA compliance | Page backgrounds don't reduce contrast |
| Dark mode | Semantic tokens adapt automatically | Custom colors also work in dark mode |
| Disabled states | Communicated via aria-disabled | Reason for disabled is accessible |
| Focus trapping | Dialog traps focus when open | You return focus to trigger on close |
</aura-coverage>
<patterns>
<pattern name="form-validation" priority="critical">
<instruction>
- Validate on blur, not on every keystroke
- Show errors inline, adjacent to the field
- Preserve user input on failure (never clear the form)
- Move focus to first error field on submission failure
- Announce errors to screen readers via aria-live
</instruction>
<status-token-mapping>
| State | Background | Text | Border |
|-------|-----------|------|--------|
| Error | bg-destructive | text-destructive-foreground | border-destructive |
| Warning | bg-warning | text-warning-foreground | — |
| Success | bg-success | text-success-foreground | — |
| Disabled | bg-disabled | text-disabled-foreground | — |
</status-token-mapping>
<message-patterns>
| Field type | Correct message | Incorrect message |
|-----------|----------------|-------------------|
| Required | "Report name is required." | "Required" |
| Email | "Email must include an @ symbol." | "Invalid" |
| Password | "At least 8 characters." | "Too short" |
| Number | "Value must be between 1 and 100." | "Invalid" |
| Date | "End date must be after start date." | "Invalid date" |
See `writing-copy.md` for full message patterns.
</message-patterns>
<field-validation-states>
Every form field must support the validation states applicable to its type. Use this table to determine which states to implement:
| Field type | required | format | length | range | uniqueness |
|-----------|----------|--------|--------|-------|------------|
| Text Input | yes | — | optional | — | optional |
| Email Input | yes | yes | — | — | optional |
| Password | yes | yes | yes | — | — |
| Number Input | yes | — | — | yes | — |
| Date Picker | yes | — | — | yes | — |
| Textarea | yes | — | yes | — | — |
| Select | yes | — | — | — | — |
| Combobox | yes | — | — | — | — |
| Checkbox | — | — | — | — | — |
| File Upload | yes | yes | — | yes (size) | — |
"yes" = must implement. "optional" = implement if relevant.
</field-validation-states>
<complete-field-example>
A complete form field with all states (default, focused, error, success, disabled):
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { HelperText } from '@/components/ui/helper-text';
{/* Default / Focused state */}
<div className="space-y-2">
<Label htmlFor="report-name">
Report name <span className="text-destructive">*</span>
</Label>
<Input
id="report-name"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={validateName}
aria-describedby="report-name-helper"
aria-invalid={!!nameError}
disabled={isSubmitting}
/>
{nameError ? (
<HelperText id="report-name-helper" variant="error">
{nameError}
</HelperText>
) : (
<HelperText id="report-name-helper">
A descriptive name for your report.
</HelperText>
)}
</div>
Key implementation details:
- Label with required indicator (asterisk in text-destructive)
- Input with aria-describedby linking to HelperText
- Input with aria-invalid reflecting error state
- Validation on blur via onBlur handler
- HelperText swaps between hint (default) and error message
- Disabled state during form submission
</complete-field-example>
</pattern>
<pattern name="loading-states" priority="critical">
<instruction>
Any action taking more than 300ms must show a loading indicator using Aura components.
</instruction>
<variants>
| Context | Pattern | Aura component |
|---------|---------|---------------|
| Page load | Skeleton screen | Skeleton |
| Button action | Button disabled + spinner | Button loading state |
| Data refresh | Overlay spinner | Spinner on existing content |
| Long operation | Progress bar + message | Progress |
</variants>
<example type="correct">
{/* Button loading during async action */}
<Button
variant="default"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save changes'
)}
</Button>
{/* Skeleton while page loads */}
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<DataTable data={reports} columns={columns} />
)}
</example>
<example type="incorrect">
{/* No loading state */}
<Button onClick={handleSave}>Save changes</Button>
{/* Blank screen while loading */}
{isLoading ? null : <DataTable data={reports} />}
</example>
</pattern>
<pattern name="error-states" priority="critical">
<instruction>
Every API failure must show a user-facing message using Aura Alert component. Never fail silently.
See `writing-copy.md` for message wording.
</instruction>
<full-example>
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error}
</AlertDescription>
<Button
variant="secondary"
size="sm"
onClick={retry}
className="ml-auto"
>
Try again
</Button>
</Alert>
)}
</full-example>
</pattern>
<pattern name="success-feedback" priority="high">
<instruction>
Use Sonner toast for brief confirmations.
</instruction>
<example type="correct">
import { toast } from 'sonner';
// After save
toast.success('Report saved successfully.');
// After delete
toast.success('Report deleted.');
// After bulk action
toast.success(`${count} items archived.`);
</example>
<example type="incorrect">
// No feedback
await saveReport(data);
navigate('/reports');
// Vague
toast.success('Done!');
</example>
</pattern>
<pattern name="confirmation-dialogs" priority="critical">
<instruction>
Destructive actions must show Dialog with specific action verb. See `writing-copy.md` for copy.
</instruction>
<full-example>
import {
Dialog, DialogContent, DialogDescription,
DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
<Dialog open={showDelete} onOpenChange={setShowDelete}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this report?</DialogTitle>
<DialogDescription>
This will permanently remove "{report.name}" and
all associated data. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="secondary"
onClick={() => setShowDelete(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
>
Delete report
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</full-example>
<example type="incorrect">
{/* Yes/No, no description, wrong variant */}
<Dialog open={show}>
<DialogContent>
<DialogTitle>Confirm</DialogTitle>
<DialogDescription>Are you sure?</DialogDescription>
<DialogFooter>
<Button variant="secondary">No</Button>
<Button variant="default">Yes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</example>
</pattern>
</patterns>
<page-accessibility priority="critical">
<responsibility name="keyboard-navigation">
<instruction>
- Tab order follows visual reading order
- Every interactive element reachable via Tab
- No keyboard traps
- Skip-to-content link on pages with complex nav
</instruction>
<example type="correct">
<a href="#main-content" className="sr-only focus:not-sr-only
focus:absolute focus:top-4 focus:left-4 focus:z-50
focus:bg-background focus:px-4 focus:py-2 focus:rounded-md
focus:shadow-focus-ring">
Skip to main content
</a>
<Sidebar />
<main id="main-content">
<h1>Reports</h1>
{/* Content in logical tab order */}
</main>
</example>
</responsibility>
<responsibility name="heading-hierarchy">
<instruction>
Use heading levels (H1H6) in strict sequential order.
- One H1 per page (the page title)
- Never skip levels (H1 directly to H3)
- Never use heading tags for visual sizing — use text-*
classes from the Typography foundation instead
Aura applies text-undefined-foreground to headings by default.
</instruction>
</responsibility>
<responsibility name="image-alt-text">
<instruction>
Every image needs alt. Icons in buttons need aria-label.
</instruction>
<examples>
| Type | Approach | Example |
|------|----------|---------|
| Informational | Describe content | alt="Chart: output up 20%" |
| Decorative | Empty | alt="" |
| Icon button | aria-label on parent | aria-label="Delete report" |
| Icon with label | Hide icon | aria-hidden="true" on icon |
</examples>
<example type="correct">
{/* Icon + text: hide icon from screen reader */}
<Button variant="destructive">
<TrashIcon className="h-4 w-4 mr-2" aria-hidden="true" />
Delete report
</Button>
{/* Icon only: label on button */}
<Button variant="ghost" size="icon" aria-label="Delete report">
<TrashIcon className="h-4 w-4" />
</Button>
</example>
</responsibility>
<responsibility name="dynamic-content">
<examples>
| Scenario | Method |
|----------|--------|
| Search results update | aria-live="polite" |
| Form error | aria-live="assertive" |
| Toast | Sonner handles this |
| Dialog opens | Focus moves to dialog (Aura handles) |
| Dialog closes | Return focus to trigger |
</examples>
<example type="correct">
{/* Screen reader announcement for filtered results */}
<div aria-live="polite" className="sr-only">
{results.length} results found for "{query}"
</div>
<DataTable data={results} columns={columns} />
</example>
</responsibility>
<responsibility name="color-independence">
<instruction>
Never use color alone to convey meaning.
</instruction>
<example type="correct">
{/* Status with text + color */}
<span className="inline-flex items-center gap-1.5
bg-success text-success-foreground text-xs px-2.5 py-0.5
rounded-full">
<CheckCircle className="h-3 w-3" aria-hidden="true" />
Active
</span>
</example>
<example type="incorrect">
{/* Color only — invisible to colorblind users */}
<span className="h-2 w-2 rounded-full bg-success" />
</example>
</responsibility>
</page-accessibility>
<translation-and-testing priority="medium">
Short sentences and simple grammar translate more reliably. Plan for text expansion in localized UIs (e.g. German often adds 3040% length); allow flexible button and title widths.
For automated checks, use WAVE, axe DevTools, or Lighthouse in Chrome DevTools. For manual verification, unplug the mouse and complete primary tasks with keyboard only; spot-check with VoiceOver (Mac) or NVDA (Windows) for critical flows.
</translation-and-testing>
<self-check>
Before submitting any page:
- [ ] Tab through all elements in logical order?
- [ ] Every button/link works with Enter/Space?
- [ ] Every dialog opens/closes with keyboard?
- [ ] Escape closes dialogs, popovers, dropdowns?
- [ ] Every image has appropriate alt text?
- [ ] Every form field has a visible label?
- [ ] Non-color indicator for every status?
- [ ] Headings follow H1 → H2 → H3?
- [ ] Dynamic updates announced to screen readers?
- [ ] Focus ring (shadow-focus-ring) visible on all elements?
</self-check>
<edge-cases>
**Forms and async**
1. Destructive action with undo? — Still confirm. Mention undo in body: "You can undo within 30 seconds."
2. Bulk delete? — One confirmation: "Delete 12 reports?"
3. Auto-save? — Subtle "Saved" indicator, not toast each time.
4. Error in multi-step flow? — Don't lose progress. Show error on current step. Let user retry.
**Accessibility**
1. Complex data viz? — Text summary via alt or sr-only text.
2. Drag-and-drop? — Keyboard alternative required.
3. Real-time dashboard? — aria-live="polite", not "assertive".
4. Third-party embed? — iframe with descriptive title.
</edge-cases>
@@ -0,0 +1,18 @@
# Picking components and applying tokens
This file is a **pointer**. Day-to-day primitive decisions live in **`primitive-usage.md`**. Canonical Storybook URLs in this repo live in **`storybook-links.md`**.
Published Aura resources (outside this repo): [Aura design system docs](https://cognite-dune-docs.mintlify.app/aura-design-system/index), [Aura Storybook (Fusion preview)](https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/), [Figma library](https://www.figma.com/design/pMnMQvfErZDJzWgrcWCIwZ/WIP---Aura-library).
## When to open what
| Need | File |
|------|------|
| Which primitive, when to use it, dos and don'ts | `primitive-usage.md` |
| Page layouts and approved patterns | `building-pages.md` |
| Interface copy | `writing-copy.md` |
| Forms, loading, errors, confirmations, page a11y | `handling-states.md` |
| Storybook URLs for foundations and components | `storybook-links.md` |
| Component props, variants, and foundation token reference | Storybook and [Aura design system docs](https://cognite-dune-docs.mintlify.app/aura-design-system/index) (this file does not duplicate them) |
Use Storybook and [Aura design system docs](https://cognite-dune-docs.mintlify.app/aura-design-system/index) for props, variants, and examples; use this skill for composition choices and consistency with Fusion and Flows apps.
+343
View File
@@ -0,0 +1,343 @@
# Aura primitive usage guidance (simplified)
## Purpose
Use this file for primitive-level decisions when building Flows and Fusion apps.
It captures usage guidance that is typically missing from component specs and prop tables.
## Resources
Links below must stay usable without Cognite VPN or internal auth. Do not use Cognite-internal short URL domains in this skill.
- Figma library: https://www.figma.com/design/pMnMQvfErZDJzWgrcWCIwZ/WIP---Aura-library
- Aura design system docs: https://cognite-dune-docs.mintlify.app/aura-design-system/index
- Aura Storybook (Fusion preview): https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/
- Storybook path index in this repo (same `/docs/...` paths; hosts may differ): `./storybook-links.md`
## What Aura is
Aura is Cognite's AI-native design system. It provides:
- visual language,
- primitive library,
- usage conventions for app UX.
Always prefer an Aura primitive before building custom UI.
## Guidance tiers
- Foundations: non-negotiable style decisions; use tokens and do not override with raw values.
- Primitives: default building blocks; use these unless there is a clear product reason not to.
- Patterns: repeatable workflows and compositions; use established patterns for consistency across apps.
## Global primitive rules
1. Prefer primitives over custom components.
2. Keep behavior accessible (keyboard activation, focus visibility, and clear state changes).
3. Do not hide critical information if users need fast comparison or repeated switching.
4. When selection is required before action, prefer contextual actions tied to that selection.
5. Use Storybook for exact variants, props, and implementation details.
## Primitive guidance
Storybook links below use the Fusion preview host; paths match `./storybook-links.md`.
### Accordion
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-accordion--docs
**Definition**
Accordion reveals and hides grouped content sections to reduce cognitive load and page density.
**Use when**
- Grouping settings in side/config panels.
- Breaking long forms into manageable sections.
- Organizing docs/FAQ/help content.
- Showing nested information hierarchies.
**Use something else when**
- All content must stay visible for comparison/scanning.
- Content is short and easy to read without progressive disclosure.
- Users are making high-stakes or multi-step decisions where hidden content can cause errors.
**Dos and don'ts**
- Do use clear, specific section titles.
- Do keep icon and heading behavior consistent.
- Do not use for very short/simple content.
- Do not nest accordions.
**Behavior**
- Header controls expand/collapse via click/tap/Enter/Space.
- Support multi-expand unless product pattern requires single-expand.
- Keep expanded content available to assistive tech.
**Often used with**
- `Separator`, section headings, and form controls inside panel content.
### Action toolbar
**Storybook:** coming soon
**Definition**
Action toolbar is a transient bottom-aligned action row that appears when users select items (for example in data-heavy views).
**Use when**
- Actions apply only to selected items.
- You need to reduce persistent toolbar clutter in tables/lists/cards.
- The workflow depends on selected state before next actions are valid.
**Use something else when**
- Actions are page-level and do not require selection first (use a standard toolbar/page actions).
**Dos and don'ts**
- Do keep actions contextual to the current selection.
- Do keep the set focused (use overflow when needed).
- Do center it in the container/page scope.
- Do not make it draggable.
**Behavior**
- Hidden by default; appears after selection.
- Anchored to bottom area; remains until selection clears, action completes, or user navigates away.
- If no reload occurs, it exits after action completion.
**Often used with**
- Selection patterns in data views, `Checkbox`, `Button`, `Menu`, and `Tooltip` for icon-only actions.
### Avatar
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-avatar--docs
**Definition**
Avatar visually represents a user, team, or concept and helps recognition in collaborative UI.
**Use when**
- Showing people in comments, chat, sharing, or collaborators.
- Representing accounts, teams, or organizations.
- Displaying AI/agent identities in conversational interfaces.
**Behavior**
- Choose size based on context density.
- Use overflow patterns for constrained spaces (for example +N with menu).
- Can be informational or interactive based on context.
- Can include status badges/dots.
**Often used with**
- `Badge`, `Tooltip`, `Menu`.
### Alert
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-alert--docs
**Definition**
Alert communicates contextual, medium-emphasis information inside page/task flow. It is not a blocking modal.
**Use when**
- Providing inline guidance/recommendations in the current task.
- Calling attention to warnings/issues that need awareness but are not blocking.
- Offering direct actions that resolve the issue in context.
**Dos and don'ts**
- Do include action buttons only when actions are directly related to resolving/dismissing the alert.
- Do evaluate simpler feedback methods first (for example field-level validation).
- Do not attach unrelated actions.
**Placement**
- Align with surrounding content; do not pin flush against dividers.
- Use card style for wrapped content in constrained areas.
- Use strip style for short messages in wider areas.
**Behavior**
- Inline with page flow (not full-screen blocking).
- Dismissal removes/hides alert per variant.
- Action path should be clear and minimal.
**Often used with**
- `Button` for direct resolution actions.
### Badge
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-badge--docs
**Definition**
Compact label for status, category, or metadata.
**Use when**
- Surfacing state at a glance (for example active, draft, error).
- Tagging items without taking primary focus from the page.
**Use something else when**
- The message needs explanation or recovery steps (consider `Alert` or inline text).
- You need a primary action (use `Button`).
**Often used with**
- `Avatar`, tables and lists, filter chips.
### Banner
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-banner--docs
**Definition**
Persistent or dismissible message scoped at page or section level — stronger than inline helper text, broader than a single-field `Alert` in some layouts.
**Use when**
- Announcing environment or product state (maintenance, trial, feature preview).
- Page-wide outcomes that should stay visible while the user continues.
**Use something else when**
- Task-specific guidance inside a flow (`Alert`).
- Brief confirmation after an action (`Sonner Toast`).
### Button
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-button--docs
**Definition**
Primary control for discrete actions.
**Use when**
- Committing, navigating a clear next step, or triggering destructive work (with confirmation pattern).
**Dos and don'ts**
- One primary action per logical section when possible.
- Match variant to risk: destructive actions use destructive variant and confirmation.
- Label with verb + object (see `writing-copy.md`).
- Icon-only actions need an accessible name (`aria-label`).
**Often used with**
- `Button Group`, `Dialog` / `Alert Dialog`, forms.
### Dialog and Alert Dialog
**Storybook:** [Dialog](https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-dialog--docs) · [Alert Dialog](https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-alert-dialog--docs)
**Definition**
- **Alert Dialog** — short, focused confirmation or acknowledgment; interrupts for a clear binary or limited choice.
- **Dialog** — richer content: forms, multi-field flows, or explanations that do not fit a strip or inline pattern.
**Use Alert Dialog when**
- Confirming destructive or irreversible actions.
- Blocking until the user chooses a small set of options.
**Use Dialog when**
- Collecting input or showing structured content that needs focus without leaving the page.
**Use something else when**
- Inline persistence is enough (`Alert`).
- Only a quick acknowledgement is needed (`Sonner Toast`).
### Drawer
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-drawer--docs
**Definition**
Secondary surface that slides in for filters, detail, or medium-length tasks without a full page change.
**Use when**
- Supporting the main view (filters, record details, auxiliary forms).
**Use something else when**
- The task needs full attention or multi-step wizard treatment (full page or `Dialog`).
- Content is very short (consider `Popover` or inline).
### Empty State
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-empty-state--docs
**Definition**
Placeholder when there is no data yet or results are empty.
**Use when**
- Lists, tables, charts, or artifacts have zero rows/points.
**Dos and don'ts**
- Explain what will appear and how to get started.
- Include a single clear CTA when creation/import applies.
### Segmented Control
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-segmented-control--docs
**Definition**
Switches between a small number of peer views or modes on the same page.
**Use when**
- Two to several comparable sections (for example overview vs details vs activity).
**Use something else when**
- Content is hierarchical or lengthy and users must open multiple sections at once (consider `Accordion` or visible sections).
- Navigating separate routes (tabs/sidebar patterns — see `building-pages.md`).
**Relationship to Accordion**
- Segmented control swaps visibility of peer panels; accordion stacks expandable sections. Prefer segmented control when users switch modes frequently; accordion when progressive disclosure matters.
### Sonner Toast
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-sonner-toast--docs
**Definition**
Lightweight, auto-dismiss feedback for outcomes that do not need a blocking surface.
**Use when**
- Confirming save, delete, or background completion.
- Non-critical notices the user can miss without breaking a workflow.
**Use something else when**
- User must read and act before continuing (`Alert Dialog`, `Dialog`, or persistent `Alert` / `Banner`).
### Table
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-table--docs
**Definition**
Dense, scannable display of rows and columns with optional selection and actions.
**Use when**
- Comparing rows, scanning many attributes, or operating on multiple items.
**Use something else when**
- A simple fixed list of links or single-column items (`List`).
- A primary chart or narrative view (`Card`, charts — see Storybook).
**Often used with**
- Selection + **Action toolbar** (when selection-gated actions apply), `Pagination`, `Empty State`, row `Checkbox`, `Dropdown Menu` for row actions.
### Toolbar
**Storybook:** https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-toolbar--docs
**Definition**
Persistent strip of primary tools or filters for a page or region — available without selecting rows first.
**Use when**
- Page-level create/filter/export actions.
- Tools that apply to the whole view or the current query.
**Use something else when**
- Actions apply only after row/item selection (use **Action toolbar** pattern).
### Tooltip, Popover, and Hover Card
**Storybook:** [Tooltip](https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-tooltip--docs) · [Popover](https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-popover--docs) · [Hover Card](https://storybook-aura-22829.fusion-preview.preview.cogniteapp.com/?path=/docs/primitives-hover-card--docs)
**Definition**
- **Tooltip** — short hint on hover/focus; no heavy interaction inside.
- **Popover** — click-triggered panel for interactive or structured supplemental content.
- **Hover Card** — richer preview on hover for entities (profiles, references).
**Use Tooltip when**
- Clarifying a control or icon in one line or sentence.
**Use Popover when**
- User picks options, fills short fields, or reads formatted content on demand.
**Use Hover Card when**
- Previewing related metadata without leaving context.
**Use something else when**
- Content is essential to the task — surface it inline or in `Dialog` / `Drawer`.
## Escalation guidance
If a primitive does not fit:
1. Check Storybook variants/props first.
2. Compose with existing primitives.
3. If still blocked, note the gap and keep implementation consistent with Aura foundations.
+133
View File
@@ -0,0 +1,133 @@
# Aura Storybook Links
Canonical reference for all Aura Storybook URLs. If the Storybook
domain or path structure changes, update this file and propagate
to individual skills.
Base URL: `https://cognitedata.github.io/aura/storybook/`
Last verified against Storybook: 2026-03-13
## Foundations
| Foundation | URL |
|-----------|-----|
| Colors | https://cognitedata.github.io/aura/storybook/?path=/docs/foundations-colors--docs |
| Effects | https://cognitedata.github.io/aura/storybook/?path=/docs/foundations-effects--docs |
| Layout | https://cognitedata.github.io/aura/storybook/?path=/docs/foundations-layout--docs |
| Typography | https://cognitedata.github.io/aura/storybook/?path=/docs/foundations-typography--docs |
## Components — Actions and Inputs
| Component | URL |
|-----------|-----|
| Button | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-button--docs |
| Button Group | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-button-group--docs |
| Input | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-input--docs |
| Input Group | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-input-group--docs |
| Textarea | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-textarea--docs |
| Select | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-select--docs |
| Combobox | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-combobox--docs |
| Checkbox | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-checkbox--docs |
| Radio Group | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-radio-group--docs |
| Switch | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-switch--docs |
| Toggle | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-toggle--docs |
| Slider | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-slider--docs |
| Date Picker | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-date-picker--docs |
| Time Input | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-time-input--docs |
| Calendar | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-calendar--docs |
## Components — Form Support
| Component | URL |
|-----------|-----|
| Label | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-label--docs |
| Helper Text | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-helper-text--docs |
## Components — Layout and Containers
| Component | URL |
|-----------|-----|
| Card | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-card--docs |
| Accordion | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-accordion--docs |
| Collapsible | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-collapsible--docs |
| Separator | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-separator--docs |
| Swap Slot | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-swap-slot--docs |
| Empty State | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-empty-state--docs |
## Components — Navigation
| Component | URL |
|-----------|-----|
| Sidebar | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-sidebar--docs |
| Topbar | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-topbar--docs |
| Breadcrumb | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-breadcrumb--docs |
| Segmented Control | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-segmented-control--docs |
| Menubar | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-menubar--docs |
| Pagination | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-pagination--docs |
| Toolbar | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-toolbar--docs |
## Components — Data Display
| Component | URL |
|-----------|-----|
| Table | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-table--docs |
| List | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-list--docs |
| Badge | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-badge--docs |
| Avatar | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-avatar--docs |
| Progress | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-progress--docs |
| Skeleton | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-skeleton--docs |
| Kbd | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-kbd--docs |
| TreeView | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-treeview--docs |
## Components — Artifact (Domain-Specific)
All Artifact sub-variants live on the same docs page (`primitives-artifact--docs`).
Chart and Count are also available as standalone components with their own docs pages.
| Component | Sub-variant | URL |
|-----------|-------------|-----|
| Artifact (Count) | Metric/count display inside an Artifact | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-artifact--docs |
| Artifact (List) | List display inside an Artifact | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-artifact--docs |
| Artifact (Progress) | Progress tracking inside an Artifact | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-artifact--docs |
| Artifact (Alert) | Alert/status inside an Artifact | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-artifact--docs |
| Artifact (Tree View) | Hierarchical data inside an Artifact | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-artifact--docs |
| Artifact (Chart) | Data visualization inside an Artifact | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-artifact--docs |
| Chart (standalone) | Data visualization outside an Artifact | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-chart--docs |
| Count (standalone) | Metric/count display outside an Artifact | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-count--docs |
## Components — Feedback and Overlays
| Component | URL |
|-----------|-----|
| Alert | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-alert--docs |
| Alert Dialog | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-alert-dialog--docs |
| Banner | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-banner--docs |
| Dialog | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-dialog--docs |
| Drawer | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-drawer--docs |
| Sonner Toast | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-sonner-toast--docs |
| Tooltip | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-tooltip--docs |
| Popover | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-popover--docs |
| Hover Card | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-hover-card--docs |
| Context Menu | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-context-menu--docs |
| Dropdown Menu | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-dropdown-menu--docs |
| Command | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-command--docs |
| Page Loader | https://cognitedata.github.io/aura/storybook/?path=/docs/primitives-page-loader--docs |
## Components — AI
| Component | URL |
|-----------|-----|
| Chain of Thought | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-chain-of-thought--docs |
| Code Block | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-code-block--docs |
| Confirmation | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-confirmation--docs |
| Conversation | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-conversation--docs |
| Inline Citation | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-inline-citation--docs |
| Loader | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-loader--docs |
| Message | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-message--docs |
| Prompt Input | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-prompt-input--docs |
| Reasoning | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-reasoning--docs |
| Shimmer | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-shimmer--docs |
| Sources | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-sources--docs |
| Suggestion | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-suggestion--docs |
| Tool | https://cognitedata.github.io/aura/storybook/?path=/docs/ai-tool--docs |
+233
View File
@@ -0,0 +1,233 @@
# Writing copy
## Role
You are writing interface copy for Cognite customer-facing applications. Every piece of UX text must be purposeful, concise, conversational, and clear. Always identify the target audience persona before writing — the persona determines reading level, technical vocabulary, and tone.
For code-level accessibility (keyboard navigation, ARIA, focus, headings, live regions), see `handling-states.md`.
## Audience personas
Canonical persona definitions live in the cogdocs repository (`cogdocs/cogdocs-metadata.mdx`, **Audience** section). This summary covers what matters for microcopy decisions.
| Persona | Technical level | UX copy implication |
|---|---|---|
| `businessUser` | Low | Plain language; outcomes over features; domain terms OK, avoid platform jargon |
| `businessDecisionMaker` | Low | Plain language; ROI, business value, strategic impact; minimal technical detail |
| `appMaker` | Mid | Configuration, automation, outcomes; avoid deep code/API detail |
| `dataAnalyst` | Mid | Analytics, insights, dashboards; data terms OK, keep explanations clear |
| `partner` | Midhigh | Precise; balance technical accuracy with clarity |
| `administrator` | High | Technical terms OK; reliability, security, compliance, access; be precise |
| `dataEngineer` | High | Technical terms OK; pipelines, ingestion, transformation |
| `developer` | High | Technical terms OK; APIs, SDKs, integrations; precise and concise |
| `aiEngineer` | High | Technical terms OK; ML/AI, models, automation |
| `dataScientist` | High | Technical terms OK; experiments, models, analytics |
| `securityEngineer` | High | Technical terms OK; IAM, threats, compliance |
| `solutionArchitect` | High | Technical terms OK; integration, strategy, best practices |
| `internal` | Varies | Can use Cognite-internal jargon; match internal conventions |
**Reading level:** Low = 7th8th grade; Mid = 9th10th grade; High = 10th11th grade.
When the persona is unknown, default to plain language and outcomes.
## Voice and tone
Voice is consistent; tone adapts to the user's emotional state.
| Scenario | Tone | Example |
|---|---|---|
| First-time onboarding | Friendly, welcoming | "Let's get started — Cognite Data Fusion is ready when you are." |
| Technical documentation | Clear, direct, supportive | "Configure your endpoint and authenticate using your API key." |
| Error messages | Empathetic, constructive | "Something went wrong. Try refreshing, or check your connection." |
| Success states | Encouraging, concise | "Your data is now flowing." |
| Product tours / help | Conversational, helpful | "Want a quick tour? We'll walk you through the essentials in under 2 minutes." |
| High-stakes actions | Serious, transparent | "Delete pipeline? All history will be permanently removed." |
## Grammar and style
### Language and capitalization
- **American English**: color, center, organization, modeling
- **Sentence case everywhere**: "Create data model" — not "Create Data Model". No exceptions for UI text. Only proper nouns and product names are capitalized: Cognite Data Fusion, OPC-UA, Aura.
- **No all-caps**
- **No "CDF" in UI copy** — customers may white-label the platform; use product or feature names instead
### Numbers and units
- **Numerals for all numbers**, including those under 10: "6 queries", "3 items", "1 result"
- Non-breaking space between number and unit: "50 Mbps"
- Don't use "(s)" or "(es)" — choose singular or plural based on context
### Abbreviations and punctuation
- No Latin abbreviations: use "for example" not "e.g.", "and more" not "etc."
- Define acronyms and technical terms when first used (unless writing for technical personas)
- No ampersands (&): use "and" — including in headings
- **Oxford comma**: "apples, oranges, and pears"
- No exclamation marks in UI copy
- No period after labels, tooltip text, or single-sentence bulleted list items; use periods for multiple/complex sentences
- Ellipsis (…): only for ongoing processes or truncated text — use sparingly
### Pronouns
- Don't mix "my" and "your" in the same context
- **"My [resource]"** for app-owned items: "My data", "My assets"
- Minimize "I" and "we" representing the application; focus on the user's perspective
- Avoid ambiguous pronouns ("this", "that") without an explicit referent — name the thing
## Action labels
Use sentence case with an object: "Edit model", "Delete asset".
### Approved labels
| Label | Use when |
|---|---|
| Add | Taking an existing object into a new context ("Add to canvas") |
| Apply | Setting filtered values that affect subsequent system behavior |
| Approve | User agrees; initiates next step in a business process |
| Back | Returning to the previous step in a sequence or hierarchy |
| Cancel | Stopping the current action or closing a modal — warn of data loss |
| Clear | Clearing all fields/selections; restores defaults |
| Close | Closing a page, panel, or secondary window — often icon-only |
| Copy | Copying an object to the clipboard |
| Create | Making a new object from scratch |
| Delete | Permanently destroying an object |
| Discard | Discarding unsaved changes during create/edit |
| Download | Transferring a file from remote to local |
| Duplicate | Creating a copy in the same location as the original |
| Edit | Changing data/values of an existing object |
| Export | Saving data in an external format; typically opens a dialog |
| Import | Bringing data from an external source; typically opens a dialog |
| Next | Advancing to the next step in a wizard |
| Finish | Completing a multi-step wizard |
| Open | Opening a drawer, modal, or new page within current context |
| Publish | Making content available to intended users |
| Refresh | Reloading a view that is out of sync with the source |
| Register | Creating a new user account |
| Remove | Removing an object from the current context without destroying it |
| Reset | Reverting to last saved or default state |
| Save | Saving pending changes without closing the window/panel |
| Search | Goal-oriented action to find precise information |
| Select | Choosing one or more options from a list |
| Show / Hide | Revealing or removing an element from view without deleting — use as a pair |
| Sign in / Sign out | Entering or exiting the application |
| Undo / Redo | Reversing or re-applying the most recent action |
| Upload | Transferring a file from local to remote |
| View | Presenting additional information or properties for an object |
### Labels to avoid
| Avoid | Use instead | Reason |
|---|---|---|
| Confirm | The specific action verb ("Delete", "Send") | Too vague |
| Log in / Log out | Sign in / Sign out | "Log" is technical jargon |
| Sign up | Register | Avoids confusion with "Sign in" |
| Submit, OK, Yes | The specific outcome verb | Generic; tell users what happens |
| Click here, Read more | Descriptive link text | Inaccessible; not input-agnostic |
## UI text patterns
### Titles
Noun phrases, sentence case. Examples: "Asset overview", "Pipeline runs", "Configure integration"
### Buttons and CTAs
Active imperative verb + object. 24 words target, 6 max. Examples: "Save changes", "Delete pipeline", "View details"
### Error messages
Pattern: `[What failed]. [Why/context if known]. [What to do].`
Examples:
- "Ingestion failed. Check your extractor configuration and try again."
- "Couldn't save changes. Connection lost. Reconnect and retry."
Avoid: blame language, dead ends with no recovery path
### Success messages
Past tense, specific, brief. Pattern: `[Action] [result]`
Avoid "successfully"; that's implied in the pattern
Examples: "Changes saved", "Pipeline started", "Integration configured"
### Empty states
Explanation + CTA. Example: "No assets yet. Connect a data source to start exploring."
### Tooltips
One to two sentences, present tense. Pattern: `[What it is]. [What it does or why it matters].`
Examples:
- "Asset ID. The unique identifier for this asset in Cognite Data Fusion."
- "Time granularity. Controls how data points are aggregated in the chart."
Never repeat the label. Never write more than 2 sentences.
### Confirmation dialogs
State the consequence, not just the action. Pattern: `[What will be lost or affected]. [Reversibility]. [Specific action].`
- Primary CTA: match the specific action ("Delete pipeline", not "Confirm")
- Secondary CTA: always provide a clear exit ("Cancel")
Examples:
- "Delete pipeline? All runs and history will be permanently removed. This can't be undone."
- "Remove team member? They'll lose access to all shared resources immediately."
Avoid: "Are you sure?", manipulative phrasing
### Form fields
- **Labels**: Clear noun phrases ("Time series ID", "Email address")
- **Placeholder text**: Use sparingly, only for standard formats like "name@example.com"
- **Helper text**: Verb-first; explain why the information is needed
### Notifications
Verb-first title + contextual description. 1015 words total.
Example: "Extractor disconnected. Check your network and reconnect."
## Accessibility
- Use **"Select"** not "Click" — input-agnostic: mouse, keyboard, touch, voice
- Avoid ambiguous pronouns — screen readers lose surrounding context
- Write descriptive link text: "Read pricing details" not "Click here"
- Alt text by image type:
- Icon → describes function: "Download PDF" not "download icon"
- Link image → describes destination: "Contact support" not "question mark"
- Chart/diagram → summarizes meaning: "Bar chart showing pipeline throughput declining 20% in Q3"
- Decorative image → empty alt text (`alt=""`)
- Never write "image of" or "photo of"
- For charts and metrics, describe key trends or values in adjacent text — don't rely on visual encoding alone
- Target 814 words per sentence (8 = 100% comprehension, 14 = 90%)
- Pair visual indicators with text: "Error: field required" alongside a red icon
## Date and time formatting
- **Prefer written dates**: "2 January 2023" not "02/01/2023"
- **Relative vs absolute**: ≤24 h from now → relative ("32 min ago"); >24 h → absolute ("2 Jan 2023")
- Always include the year unless obvious from context
- No ordinal numbers: "2 January" not "2nd January"
- Separate date and time with "at": "2 Jan 2023 at 10:00 AM" — no comma
- **12-hour time**: uppercase AM/PM, no periods, space before: "10:00 AM"
- **Time zone**: UTC only; spell out "UTC" in text-only contexts
- Never make the user convert time zones — handle in code
- Ranges: consistent format across start and end; for ongoing processes use absolute start + "ongoing" until complete
- Duration: no comma between units ("10 minutes 3 seconds"); space between number and unit in running text ("3 min"); no space in controls ("3min")
**Time unit abbreviations** (no periods; same form singular/plural):
ms, s, min, hr, d, wk, mo, yr
**Day abbreviations** (3 chars for i18n):
Mon, Tue, Wed, Thu, Fri, Sat, Sun
**Month abbreviations** (4 chars for i18n):
Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec
## Localization
- Keep sentences short with the subject near the start — compound clauses increase translation cost
- Maintain consistent terminology and capitalization across strings (critical for translation memory)
- No Latin abbreviations in translatable strings: "for example" not "e.g.", "and more" not "etc."
- Avoid idioms and cultural references
- No ampersands: use "and"
- Small words (a, the, that, is): include in prose; may omit only in space-constrained labels and CTAs
## Benchmarks
| Element | Target | Maximum |
|---|---|---|
| Buttons / CTAs | 24 words | 6 words |
| Titles | 36 words, 40 characters | — |
| Tooltips | 1020 words | 2 sentences |
| Error messages | 1218 words | — |
| Instructions | 14 words | 20 words |
| Notifications | 1015 words total | — |
| Line length | 4060 characters | 70 characters |
@@ -0,0 +1,613 @@
---
name: dm-limits-and-best-practices
description: "Reference skill for CDF Data Modeling API best practices. Covers concurrency limits (avoiding 429s), pagination patterns for instances.list and instances.query, batching write operations, search vs filter guidance, and the QueuedTaskRunner (Semaphore) utility for controlling concurrent requests. Triggers: DMS limits, 429 error, rate limit, pagination, cursor, nextCursor, batching, semaphore, QueuedTaskRunner, cdfTaskRunner, instances.search, instances.list, instances.query, instances.upsert, concurrency, deadlock."
allowed-tools: Read, Glob, Grep, Edit, Write
metadata:
argument-hint: ""
---
# CDF Data Modeling: Limits, Concurrency & Best Practices
This is a reference skill. When writing or reviewing code that calls CDF Data Modeling APIs, apply the patterns below.
---
## DMS Limits Reference
For the latest concurrency limits, resource limits, and property value limits, see the official documentation:
**https://docs.cognite.com/cdf/dm/dm_reference/dm_limits_and_restrictions**
Key things to be aware of:
- Instance **apply**, **delete**, and **query** operations each have their own concurrent request limits
- Exceeding these limits returns **429 Too Many Requests**
- Transformations consume a large portion of the concurrency budget, leaving less for other clients
- `instances.list` has a max page size (use pagination for complete results)
- `instances.query` table expressions each have their own item limit
- `instances.upsert` accepts up to 1000 items per call
- `in` filters accept at most 1000 values per expression; larger sets must be split into batches
---
## Search vs Filter: When to Use Which
### `instances.search` — Free-text search on text properties
Use `instances.search` when you need fuzzy/text matching on string fields (names, descriptions, etc.). It supports an `operator` parameter:
- **`AND`** (default) — Narrow search. All terms must match. Use when the user provides a specific query.
- **`OR`** — Broad "shotgun" search. Any term can match. Use for exploratory/typeahead search where you want maximum recall.
```typescript
// Narrow search: find a specific cell by name (AND — all terms must match)
const exactResults = await client.instances.search({
view: { type: 'view', ...PROCESS_CELL_VIEW },
query: 'reactor tank A',
properties: ['name'],
operator: 'AND',
limit: 10,
});
// Broad search: typeahead/autocomplete (OR — any term can match)
const broadResults = await client.instances.search({
view: { type: 'view', ...BATCH_VIEW },
query: 'BUDE completed',
properties: ['name', 'description', 'batchStatus'],
operator: 'OR',
limit: 10,
});
```
You can combine `search` with `filter` to further constrain results with exact-match conditions:
```typescript
// Text search + exact filter: search for "pump" but only in active nodes
const filtered = await client.instances.search({
view: { type: 'view', ...PROCESS_CELL_VIEW },
query: 'pump',
properties: ['name', 'description'],
filter: {
equals: {
property: getContainerProperty(MY_CONTAINER, 'status'),
value: 'active',
},
},
limit: 20,
});
```
### `instances.list` / `instances.query` with `filter` — Exact-match filtering
Use `filter` when you need precise, deterministic matching (equals, range, in, hasData, etc.). No fuzzy matching — values must match exactly.
```typescript
// Exact match: get all completed batches
const completedBatches = await client.instances.list({
instanceType: 'node',
sources: [{ source: { type: 'view', ...BATCH_VIEW } }],
filter: {
equals: {
property: getContainerProperty(BATCH_CONTAINER, 'batchStatus'),
value: 'completed',
},
},
limit: 1000,
});
```
### Decision Guide
| Need | Use |
| ----------------------------------- | ----------------------------- |
| User typing in a search box | `instances.search` with `OR` |
| Find a specific item by name | `instances.search` with `AND` |
| Filter by status, date range, enums | `filter` on list/query |
| Text search + exact constraints | `instances.search` + `filter` |
### `in` filter value limit (1000) and batching
CDF `in` filters support a maximum of 1000 values in a single filter expression. If you need to filter against more than 1000 IDs, split values into chunks and issue multiple requests, then merge results.
```typescript
const IN_FILTER_BATCH_SIZE = 1000;
// Reuse the Chunking Utility defined in the Batching Write Operations section.
async function listByExternalIds(
client: CogniteClient,
externalIds: string[],
): Promise<NodeOrEdge[]> {
const idBatches = chunk(externalIds, IN_FILTER_BATCH_SIZE);
const responses = await Promise.all(
idBatches.map((batch) =>
cdfTaskRunner.schedule(() =>
client.instances.list({
instanceType: 'node',
sources: [{ source: { type: 'view', ...MY_VIEW } }],
filter: {
in: {
property: ['node', 'externalId'],
values: batch,
},
},
limit: 1000,
})
)
)
);
return responses.flatMap((r) => r.items);
}
```
---
## QueuedTaskRunner (Semaphore)
**Always use the global `cdfTaskRunner`** to wrap CDF API calls. It limits concurrent requests and prevents 429 errors and deadlocks.
### Source Code
If the project does not already have a semaphore utility, create `src/shared/utils/semaphore.ts` with this implementation:
```typescript
/**
* AbortError thrown when a queued task is cancelled
*/
export class AbortError extends Error {
public constructor(message: string = 'Aborted') {
super(message);
this.name = 'AbortError';
}
}
type PendingTask<AsyncFn, AsyncFnResult> = {
resolve: (result: AsyncFnResult) => void;
reject: (error: unknown) => void;
fn: AsyncFn;
key?: string;
};
const DEFAULT_MAX_CONCURRENT_TASKS = 15;
/**
* QueuedTaskRunner for controlling concurrent operations
* Used to limit concurrent CDF API requests to avoid rate limiting and deadlocks
* Essentially a semaphore that allows a limited number of tasks to run at once.
*/
export default class QueuedTaskRunner<
AsyncFn extends () => Promise<AsyncFnResult>,
AsyncFnResult = Awaited<ReturnType<AsyncFn>>,
> {
private pendingTasks: PendingTask<AsyncFn, AsyncFnResult>[] = [];
private currentPendingTasks: number = 0;
private readonly maxConcurrentTasks: number = 1;
public constructor(
maxConcurrentTasks: number = DEFAULT_MAX_CONCURRENT_TASKS
) {
this.maxConcurrentTasks = maxConcurrentTasks;
}
public schedule(
fn: AsyncFn,
options: { key?: string } = {}
): Promise<AsyncFnResult> {
this.startTrackingTime();
return new Promise((resolve, reject) => {
if (options.key !== undefined) {
// Cancel existing tasks with the same key (deduplication)
this.pendingTasks
.filter((task) => task.key === options.key)
.forEach((task) => task.reject(new AbortError()));
this.pendingTasks = this.pendingTasks.filter(
(task) => task.key !== options.key
);
}
this.pendingTasks.push({
resolve,
reject,
fn,
key: options.key,
});
this.attemptConsumingNextTask();
});
}
public async attemptConsumingNextTask(): Promise<void> {
if (this.pendingTasks.length === 0) return;
if (this.currentPendingTasks >= this.maxConcurrentTasks) return;
const pendingTask = this.pendingTasks.shift();
if (pendingTask === undefined) {
throw new Error('pendingTask is undefined, this should never happen');
}
this.currentPendingTasks++;
const { fn, resolve, reject } = pendingTask;
try {
const result = await fn();
resolve(result);
} catch (e) {
reject(e);
} finally {
this.currentPendingTasks--;
this.tick();
this.attemptConsumingNextTask();
}
}
public clearQueue = (): void => {
this.pendingTasks = [];
};
private startTime: number | null = null;
private startTrackingTime = (): void => {
if (this.startTime === null) {
this.startTime = performance.now();
}
};
private tick = (): void => {
if (this.pendingTasks.length === 0) {
this.startTime = null;
}
};
}
/**
* Global task runner for CDF API requests
* Limits concurrent requests to avoid 429 rate limiting and deadlocks
*/
export const cdfTaskRunner = new QueuedTaskRunner(DEFAULT_MAX_CONCURRENT_TASKS);
```
### Usage Pattern
Always wrap CDF calls with `cdfTaskRunner.schedule()`:
```typescript
import { cdfTaskRunner } from '../../../../shared/utils/semaphore';
// Single query
export async function fetchBatches(client: CogniteClient): Promise<CDFBatch[]> {
return cdfTaskRunner.schedule(async () => {
const response = await client.instances.query({
with: { /* ... */ },
select: { /* ... */ },
});
return response.items?.nodes || [];
});
}
// Multiple parallel queries (safe — the semaphore limits concurrency)
export async function enrichBatch(
client: CogniteClient,
batch: CDFBatch
): Promise<BatchEnrichment> {
const [currentOp, lastOp, cells, material] = await Promise.all([
fetchCurrentOperation(client, batch.space, batch.externalId),
fetchLastCompletedOperation(client, batch.space, batch.externalId),
fetchProcessCells(client, batch.space, batch.externalId),
fetchMaterial(client, batch.space, batch.externalId),
]);
return { currentOp, lastOp, cells, material };
}
// Each of the above functions internally uses cdfTaskRunner.schedule(),
// so Promise.all is safe — the semaphore prevents exceeding concurrency limits
```
### Deduplication with Keys
Use the `key` option to cancel stale requests when the same query is triggered again (e.g., user changes filters quickly):
```typescript
const result = await cdfTaskRunner.schedule(
async () => client.instances.query({ /* ... */ }),
{ key: `batch-flow-${batchId}` }
);
// If another call with the same key arrives before this completes,
// the previous pending call is rejected with AbortError
```
---
## Pagination
DMS `instances.list` returns at most `limit` items and a `nextCursor` for the next page.
DMS `instances.query` uses a `cursors` object keyed by table expression name.
### instances.list Pagination
```typescript
async function fetchAllNodes(client: CogniteClient): Promise<CDFNodeResponse[]> {
const allItems: CDFNodeResponse[] = [];
let cursor: string | undefined = undefined;
do {
const response = await client.instances.list({
instanceType: 'node',
sources: [{ source: { type: 'view', ...MY_VIEW } }],
filter: {
equals: {
property: getContainerProperty(MY_CONTAINER, 'status'),
value: 'active',
},
},
limit: 1000,
cursor,
});
allItems.push(...response.items);
cursor = response.nextCursor;
} while (cursor);
return allItems;
}
```
### instances.query Pagination
The `query` endpoint returns `nextCursor` as a `Record<string, string>` (one cursor per table expression). Use it via the `cursors` parameter:
```typescript
import { isEmpty } from 'lodash';
async function fetchAllResults(
client: CogniteClient
): Promise<{ results: CDFResult[]; edges: EdgeDefinition[] }> {
const QUERY_LIMIT = 10_000;
const fetchPage = async (
nextCursors?: Record<string, string>
): Promise<{ results: CDFResult[]; edges: EdgeDefinition[] }> => {
const { items, nextCursor } = await client.instances.query({
with: {
results: {
limit: QUERY_LIMIT,
nodes: {
filter: {
hasData: [{ type: 'view', ...RESULT_VIEW }],
},
},
},
relatedEdges: {
limit: QUERY_LIMIT,
edges: {
from: 'results' as const,
maxDistance: 1,
direction: 'outwards' as const,
filter: {
equals: {
property: ['edge', 'type'],
value: MY_EDGE_TYPE,
},
},
},
},
},
cursors: nextCursors, // Pass cursors from previous page
select: {
results: {
sources: [
{ source: { type: 'view', ...RESULT_VIEW }, properties: ['*'] },
],
},
relatedEdges: {},
},
});
const results = (items?.results || []) as CDFResult[];
const edges = (items?.relatedEdges || []).filter(
(e) => e.instanceType === 'edge'
);
// Recurse if more pages exist
if (!isEmpty(nextCursor)) {
const next = await fetchPage(nextCursor);
return {
results: [...results, ...next.results],
edges: [...edges, ...next.edges],
};
}
return { results, edges };
};
return fetchPage();
}
```
### Pagination + QueuedTaskRunner Combined
Always wrap paginated fetches with the semaphore to avoid saturating the concurrency budget:
```typescript
export async function fetchAllWithPagination(
client: CogniteClient
): Promise<CDFNodeResponse[]> {
return cdfTaskRunner.schedule(async () => {
const allItems: CDFNodeResponse[] = [];
let cursor: string | undefined = undefined;
do {
const response = await client.instances.list({
instanceType: 'node',
sources: [{ source: { type: 'view', ...MY_VIEW } }],
filter: { /* ... */ },
limit: 1000,
cursor,
});
allItems.push(...response.items);
cursor = response.nextCursor;
// Optional: break early if you have enough data
if (allItems.length >= 500) break;
} while (cursor);
return allItems;
});
}
```
---
## Batching Write Operations
When upserting many instances, chunk them to stay under the apply concurrency limit. Each `instances.upsert` call accepts up to 1000 items.
### Chunking Utility
```typescript
function chunk<T>(arr: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size));
}
return chunks;
}
```
### Batched Upsert with QueuedTaskRunner
```typescript
const UPSERT_BATCH_SIZE = 1000;
async function batchUpsertNodes(
client: CogniteClient,
nodes: NodeOrEdgeCreate[]
): Promise<void> {
const chunks = chunk(nodes, UPSERT_BATCH_SIZE);
// Process chunks through the semaphore — safe even with Promise.all
await Promise.all(
chunks.map((batch) =>
cdfTaskRunner.schedule(async () => {
await client.instances.upsert({
items: batch,
});
})
)
);
}
```
### Batched Delete with QueuedTaskRunner
Instance deletes have an even stricter concurrency limit. Use a separate, more restrictive task runner:
```typescript
import QueuedTaskRunner from '../../../../shared/utils/semaphore';
// Dedicated runner for deletes (stricter concurrency — check docs for current limit)
const deleteTaskRunner = new QueuedTaskRunner(2);
async function batchDeleteNodes(
client: CogniteClient,
nodeIds: { space: string; externalId: string }[]
): Promise<void> {
const chunks = chunk(nodeIds, 1000);
for (const batch of chunks) {
await deleteTaskRunner.schedule(async () => {
await client.instances.delete(
batch.map((id) => ({
instanceType: 'node' as const,
...id,
}))
);
});
}
}
```
---
## Common Pitfalls
### 1. Deadlocks from Nested Semaphore Calls
If function A holds a semaphore slot and calls function B which also needs a slot, you can deadlock if all slots are occupied. **Keep the semaphore at the outermost call level**, or ensure inner calls don't go through the same semaphore.
```typescript
// BAD: Nested semaphore — can deadlock
async function fetchAndEnrich(client: CogniteClient) {
return cdfTaskRunner.schedule(async () => {
const batches = await fetchBatches(client); // This also calls cdfTaskRunner.schedule!
// If all slots are held by fetchAndEnrich callers, fetchBatches will never run
});
}
// GOOD: Let inner functions own the semaphore
async function fetchAndEnrich(client: CogniteClient) {
const batches = await fetchBatches(client); // Has its own semaphore call
const enriched = await Promise.all(
batches.map((b) => enrichBatch(client, b)) // Each has its own semaphore call
);
return enriched;
}
```
### 2. Forgetting Pagination
DMS returns at most `limit` items. If you don't paginate, you silently lose data. Always check `nextCursor`:
```typescript
// BAD: May miss data
const response = await client.instances.list({ limit: 1000, /* ... */ });
const items = response.items; // Could be incomplete!
// GOOD: Paginate
const allItems = [];
let cursor;
do {
const response = await client.instances.list({ limit: 1000, cursor, /* ... */ });
allItems.push(...response.items);
cursor = response.nextCursor;
} while (cursor);
```
### 3. Unbounded Promise.all Without Semaphore
Firing many parallel API calls will hit the 429 limit immediately:
```typescript
// BAD: Too many simultaneous requests
await Promise.all(batchIds.map((id) => client.instances.query({ /* ... */ })));
// GOOD: Each call goes through the semaphore
await Promise.all(
batchIds.map((id) =>
cdfTaskRunner.schedule(() => client.instances.query({ /* ... */ }))
)
);
```
### 4. Query Limit per Table Expression
Each table expression in `instances.query` has its own `limit`. If your traversal might return more items than the limit in a single expression, you must paginate using the `cursors` parameter.
### 5. Oversized `in` Filters
`in` filters are capped at 1000 values per expression. Passing more than 1000 values in a single `in` filter can fail or produce incomplete behavior depending on endpoint/version. Always chunk the values and run batched requests.
---
## Summary Checklist
- [ ] Wrap all CDF API calls with `cdfTaskRunner.schedule()`
- [ ] Paginate `instances.list` calls using `cursor` / `nextCursor`
- [ ] Paginate `instances.query` calls using `cursors` / `nextCursor` when data may exceed limits
- [ ] Chunk write operations to 1000 items per `instances.upsert` call
- [ ] Use a separate, stricter task runner for deletes
- [ ] Avoid nesting `cdfTaskRunner.schedule()` calls to prevent deadlocks
- [ ] Use `Promise.all` with semaphore-wrapped functions, never with raw API calls
- [ ] Use `instances.search` for text matching, `filter` for exact-match queries
- [ ] Split `in` filter values into batches of at most 1000 and merge responses
- [ ] Refer to https://docs.cognite.com/cdf/dm/dm_reference/dm_limits_and_restrictions for current limits
+40
View File
@@ -0,0 +1,40 @@
---
name: flows-code-review
description: >-
Run a full Flows app platform review against a React/TypeScript CDF codebase,
following the cognitedata/dune-app-reviews scoring criteria. Produces three
artifacts: review-files.md (per-file inventory), review-packages.md (dependency
audit), and review-report.md (scored report with must/should/nice-fix items).
Use when the user asks for a Flows app review, pre-submit review, approval
review, app certification review, code quality audit, CDF platform review, or
"run dune-review" on a codebase before submission.
allowed-tools: Read, Glob, Grep, Shell, Write
---
# Flows Code Review
Fetch the official review command and follow it exactly:
```bash
gh api repos/cognitedata/dune-app-reviews/contents/.claude/commands/dune-review.md \
--jq '.content' | base64 -d
```
Adapt it for a **local developer review**:
- Treat the **current workspace** as the app under review.
- Skip all ticket, PR, overview, submodule, and `reviews/<TICKET-ID>/...` setup steps.
- If the upstream command asks for Jira ticket or PR input, ignore that requirement and continue with the local codebase.
- Use `reviews/flows-code-review/feedback-round-<N>/` as the artifact directory for local reviews.
- If no local feedback round exists yet, use `reviews/flows-code-review/feedback-round-1/`. For reruns, increment the round number.
After the review artifacts are written, fetch the official verification command and follow it too:
```bash
gh api repos/cognitedata/dune-app-reviews/contents/.claude/commands/dune-review-verify.md \
--jq '.content' | base64 -d
```
Adapt verification the same way:
- Skip ticket and feedback-round lookup.
- Read the three artifacts from `reviews/flows-code-review/feedback-round-<N>/` instead of `reviews/<TICKET-ID>/feedback-round-N/`.
- Verify the review against the local source code before declaring it complete.
+103
View File
@@ -0,0 +1,103 @@
---
name: graph-viewer
description: Integrate the reusable CDF graph viewer (useGraphViewer) into a Flows app by copying the local code bundle. Use when embedding a graph visualization, adding a knowledge graph, or showing CDF data model relationships and instances.
---
# Graph Viewer
## Use This When
The user wants to embed an interactive graph of a CDF data model — nodes, direct relations, edges, and reverse relations — inside a Flows app.
Do **not** use this skill for static diagrams, pure dataflow visualizations, or non-CDF graphs.
## Prerequisites
- The app is wrapped in `@cognite/dune`'s `<DuneProvider>` so `useDune()` returns an authenticated SDK.
- The target data model exists in CDF and you know its `space`, `externalId`, and `version`.
- The app uses React 18+ and TypeScript.
## Integration Workflow
Follow these steps in order. Adapt to the target repo's conventions instead of inventing new ones.
1. **Inspect the target app.** Read `package.json` and look at the existing folder structure (e.g. `src/features/*`, `src/components/*`, path aliases like `@/*`).
2. **Install missing dependencies** with the app's package manager (`npm`, `pnpm`, `yarn`, …). See the [Dependencies](#dependencies) table below for purposes and suggested versions. Reuse the React version already pinned by the app rather than upgrading it, and prefer any versions the repo already pins over the suggestions here.
3. **Copy the bundle into the app.** Copy every file from `skills/graph-viewer/code/` into an app-local feature folder, for example:
```text
src/features/graph-viewer/
```
If the repo already has a different feature/components layout or alias, mirror it.
4. **Import from the local folder**, never from `@skills/...`. With a typical `@/*` alias:
```tsx
import { useGraphViewer } from "@/features/graph-viewer";
```
5. **Render `GraphCanvas` inside a container with explicit dimensions** (height is required — see the minimal example below).
6. **Run typecheck and build** (`tsc --noEmit`, `npm run build`, etc.) and fix any path or type issues introduced by the copy.
## Minimal Example
```tsx
import { useGraphViewer } from "@/features/graph-viewer";
export function GraphPanel() {
const { GraphCanvas, isLoading, error } = useGraphViewer({
dataModel: { space: "my-space", externalId: "my-data-model", version: "1" },
instance: { space: "my-instance-space", externalId: "pump-001" },
});
if (isLoading) return <div>Loading graph…</div>;
if (error) return <div>Error: {error}</div>;
return <GraphCanvas className="h-[600px] w-full" />;
}
```
## Dependencies
Suggested versions reflect the latest published majors at the time of writing. They are starting points — if the target app already pins different versions, defer to the app.
| Package | Suggested version | Purpose |
| --------------- | ----------------- | ---------------------------------------------- |
| `react` | `^18.2.0` | UI framework (peer; reuse the app's version) |
| `@cognite/sdk` | `^10.10.0` | CDF API client (instances, data models) |
| `@cognite/dune` | `^2.1.0` | Provides the authenticated SDK via `useDune()` |
| `reagraph` | `^4.30.8` | WebGL graph rendering engine |
| `lucide-react` | `^1.14.0` | Icon set used by the node-type legend |
Example install (npm; adapt to the app's package manager):
```bash
npm install @cognite/sdk@^10.10.0 @cognite/dune@^2.1.0 reagraph@^4.30.8 lucide-react@^1.14.0
```
## CDF Cost & Performance
Graph expansion can issue many CDF requests, especially with reverse relations. For large or unfamiliar data models, be conservative:
- Set `whitelistedRelationProps` to the few properties the app actually needs to traverse.
- Lower `initialConnectionLimit` (it is a **hard maximum** of connections fetched per expansion).
- Lower `maxNodes` to bound the in-memory LRU buffer.
- Only declare `coreReverseQueries` for relations the app must surface; each entry adds an extra query per expansion.
Tuples in `coreReverseQueries` are **version-aware**:
`[space, viewExternalId, viewVersion, propertyName, isList]`.
## Advanced Reference
For full configuration tables, return-value docs, layouts, theming, and richer examples, read `code/README.md`.
For implementation details, inspect the source files under `code/`.
## Verification Checklist
- [ ] The app is wrapped in `<DuneProvider>`.
- [ ] All files from `skills/graph-viewer/code/` were copied into an app-local folder.
- [ ] Imports point to the app-local folder (e.g. `@/features/graph-viewer`), not `@skills/...`.
- [ ] `@cognite/dune`, `@cognite/sdk`, `reagraph`, and `lucide-react` are present in `package.json`.
- [ ] The container that renders `<GraphCanvas>` has an explicit height.
- [ ] `tsc --noEmit` and the app's build both pass.
- [ ] No references to `dune-industrial-components` were introduced.
@@ -0,0 +1,177 @@
import { useCallback, useRef } from "react";
import {
GraphCanvas as ReagraphCanvas,
type GraphCanvasRef,
type LayoutTypes,
} from "reagraph";
import type { Theme } from "reagraph";
import type { GraphData, GraphEdge, GraphNode, LayoutType } from "./types";
import { ZoomControls } from "./ZoomControls";
import { GraphViewerLegend } from "./GraphViewerLegend";
import { useCanvasResize } from "./useCanvasResize";
import type { LiteFeatureFlags } from "./types";
const LAYOUT_MAP: Record<LayoutType, LayoutTypes> = {
forceDirected2d: "forceDirected2d",
forceDirected3d: "forceDirected3d",
treeTd2d: "treeTd2d",
treeLr2d: "treeLr2d",
radialOut2d: "radialOut2d",
circular2d: "circular2d",
};
const DOUBLE_CLICK_MS = 300;
export interface GraphViewerCanvasProps {
reagraphNodes: Array<{
id: string;
label: string;
fill: string;
icon: string;
data: GraphNode;
}>;
reagraphEdges: Array<{
id: string;
source: string;
target: string;
label?: string;
fill?: string;
size?: number;
data: GraphEdge;
}>;
displayedGraphData: GraphData;
layout: LayoutType;
theme: Theme;
selections: string[];
selectedNode: GraphNode | null;
selectedEdge: GraphEdge | null;
features: LiteFeatureFlags;
selectedNodeType: string | null;
graphRef: React.RefObject<GraphCanvasRef | null>;
onNodeClick: (node: GraphNode) => void;
onEdgeClick: (edge: GraphEdge) => void;
onCanvasClick: () => void;
onExpandNode: (nodeId: string) => void;
onNodeTypeClick: (typeKey: string) => void;
onClearNodeTypeSelection: () => void;
className?: string;
}
export function GraphViewerCanvas({
reagraphNodes,
reagraphEdges,
displayedGraphData,
layout,
theme,
selections,
features,
selectedNodeType,
graphRef,
onNodeClick,
onEdgeClick,
onCanvasClick,
onExpandNode,
onNodeTypeClick,
onClearNodeTypeSelection,
className,
}: GraphViewerCanvasProps) {
const canvasContainerRef = useRef<HTMLDivElement>(null);
const lastClickedIdRef = useRef<string | null>(null);
const lastClickTimeRef = useRef(0);
const pendingClickRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useCanvasResize(canvasContainerRef, graphRef as React.RefObject<GraphCanvasRef>);
const handleNodeClick = useCallback(
(node: { id: string }) => {
const graphNode = displayedGraphData.nodes.find((n) => n.id === node.id);
if (!graphNode) return;
const now = Date.now();
const isDoubleClick =
lastClickedIdRef.current === node.id &&
now - lastClickTimeRef.current < DOUBLE_CLICK_MS;
lastClickedIdRef.current = node.id;
lastClickTimeRef.current = now;
if (isDoubleClick && features.enableNodeExpansion) {
if (pendingClickRef.current) {
clearTimeout(pendingClickRef.current);
pendingClickRef.current = null;
}
onExpandNode(node.id);
return;
}
pendingClickRef.current = setTimeout(() => {
pendingClickRef.current = null;
onNodeClick(graphNode);
}, DOUBLE_CLICK_MS);
},
[displayedGraphData.nodes, onNodeClick, onExpandNode, features.enableNodeExpansion]
);
const handleEdgeClick = useCallback(
(edge: { id: string }) => {
const graphEdge = displayedGraphData.connections.find((c) => c.id === edge.id);
if (graphEdge) {
onEdgeClick(graphEdge);
}
},
[displayedGraphData.connections, onEdgeClick]
);
const hasNodes = reagraphNodes.length > 0;
return (
<div
ref={canvasContainerRef}
className={`relative w-full h-full min-h-0 min-w-0 ${className ?? ""}`}
>
{hasNodes && (
<ReagraphCanvas
ref={graphRef}
nodes={reagraphNodes}
edges={reagraphEdges}
layoutType={LAYOUT_MAP[layout]}
theme={theme}
labelType="nodes"
edgeLabelPosition="natural"
edgeArrowPosition="end"
draggable
animated
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onCanvasClick={onCanvasClick}
selections={selections}
cameraMode={layout.includes("3d") ? "rotate" : "pan"}
/>
)}
{!hasNodes && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm">
No nodes to display
</div>
)}
{features.enableZoomControls && hasNodes && (
<ZoomControls
onZoomIn={() => graphRef.current?.zoomIn()}
onZoomOut={() => graphRef.current?.zoomOut()}
onFitView={() => graphRef.current?.fitNodesInView()}
/>
)}
{features.enableLegend &&
displayedGraphData.nodeTypes.length > 0 && (
<GraphViewerLegend
nodeTypes={displayedGraphData.nodeTypes}
selectedNodeType={selectedNodeType}
onNodeTypeClick={onNodeTypeClick}
onClearSelection={onClearNodeTypeSelection}
/>
)}
</div>
);
}
@@ -0,0 +1,122 @@
import * as LucideIcons from "lucide-react";
import type { NodeTypeInfo } from "./types";
import { getIconForType } from "./types";
type LucideIconComponent = React.ComponentType<{
size?: number;
color?: string;
strokeWidth?: number;
className?: string;
}>;
function getLucideIcon(iconName: string): LucideIconComponent {
const icons = LucideIcons as unknown as Record<string, LucideIconComponent>;
const icon = icons[iconName];
if (!icon) {
console.warn(`[getLucideIcon] Icon "${iconName}" not found, using Circle`);
}
return icon || icons.Circle;
}
const POSITION_CLASSES = {
"bottom-left": "bottom-4 left-4",
"bottom-right": "bottom-4 right-4",
"top-left": "top-4 left-4",
"top-right": "top-4 right-4",
};
export interface LegendProps {
nodeTypes: NodeTypeInfo[];
selectedNodeType: string | null;
onNodeTypeClick: (typeKey: string) => void;
onClearSelection: () => void;
maxVisibleTypes?: number;
position?: keyof typeof POSITION_CLASSES;
}
export function GraphViewerLegend({
nodeTypes,
selectedNodeType,
onNodeTypeClick,
onClearSelection,
maxVisibleTypes = 12,
position = "bottom-left",
}: LegendProps) {
if (!nodeTypes || nodeTypes.length === 0) {
return null;
}
const visibleTypes = nodeTypes.slice(0, maxVisibleTypes);
const remainingCount = nodeTypes.length - maxVisibleTypes;
return (
<div
className={`absolute ${POSITION_CLASSES[position]} bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-gray-950/50 p-3 max-w-sm z-10`}
>
<p className="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-2 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-[#00205B] dark:bg-primary animate-pulse" />
Node Types ({nodeTypes.length})
{selectedNodeType && (
<button
type="button"
onClick={onClearSelection}
className="ml-auto text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
title="Clear filter"
>
</button>
)}
</p>
<div className="flex flex-wrap gap-2">
{visibleTypes.map((nodeType) => {
const iconName = getIconForType(nodeType.externalId);
const IconComponent = getLucideIcon(iconName);
const typeKey = `${nodeType.space}:${nodeType.externalId}`;
const isSelected = selectedNodeType === typeKey;
return (
<button
type="button"
key={typeKey}
onClick={() => onNodeTypeClick(typeKey)}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-all cursor-pointer border ${
isSelected
? "bg-[#00205B] border-[#00205B] dark:bg-primary dark:border-primary shadow-md"
: "bg-gray-50 border-gray-200 hover:bg-gray-100 hover:border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-500"
}`}
title={`Click to highlight ${nodeType.externalId} nodes`}
>
<div className="w-5 h-5 relative shrink-0">
<div
className="absolute inset-0 rounded-full shadow-sm"
style={{ backgroundColor: nodeType.color }}
/>
<div className="absolute inset-0 flex items-center justify-center">
<IconComponent size={11} className="shrink-0" color="#ffffff" strokeWidth={2.5} />
</div>
</div>
<span
className={`text-xs truncate max-w-[80px] font-medium ${
isSelected ? "text-white" : "text-gray-700 dark:text-gray-200"
}`}
title={nodeType.externalId}
>
{nodeType.externalId}
</span>
<span
className={`text-xs tabular-nums ${isSelected ? "text-gray-300 dark:text-white/70" : "text-gray-400 dark:text-gray-500"}`}
>
{nodeType.count}
</span>
</button>
);
})}
{remainingCount > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500 px-2 py-1">
+{remainingCount} more
</span>
)}
</div>
</div>
);
}
+293
View File
@@ -0,0 +1,293 @@
# Graph Viewer — Component Reference
An interactive graph visualization for exploring **Cognite Data Fusion (CDF)** data model instances and their relationships. Built on [reagraph](https://github.com/reaviz/reagraph), it exposes a single hook — `useGraphViewer` — that returns a ready-to-render canvas and a full set of programmatic controls.
> This document is the **complete API reference** for the bundle in this folder. For the agent-facing integration workflow, see `../SKILL.md`.
---
## Features
- **Data model-aware** — automatically loads CDF data model metadata to resolve node types, icons, and colors.
- **Progressive exploration** — starts from a seed instance and lets users expand the graph by double-clicking nodes to fetch connected instances (edges, direct relations, and configurable reverse relations).
- **LRU node buffer** — keeps the graph performant by evicting least-recently-used nodes when `maxNodes` is exceeded.
- **Multiple layouts** — Force-directed (2D/3D), tree (top-down / left-right), radial, and circular.
- **Interactive legend** — color-coded node type legend with click-to-filter.
- **Zoom controls** — built-in zoom in/out and fit-to-view buttons.
- **Theming** — fully customizable node, edge, ring, arrow, and canvas colors via `GraphThemeConfig` and `GraphVisualConfig`.
- **Type-aware icons** — maps CDF view types (ISA-95 assets, equipment, files, time series, etc.) to SVG icons rendered inside node circles.
---
## API
### `useGraphViewer(config): UseGraphViewerReturn`
#### `UseGraphViewerConfig`
| Field | Type | Required | Description |
| ------------ | -------------------------------- | -------- | ----------------------------------------- |
| `dataModel` | `{ space, externalId, version }` | Yes | The CDF data model to load. |
| `instance` | `{ space, externalId }` | No | Optional seed instance to load on mount. |
| `options` | `UseGraphViewerOptions` | No | Optional overrides (see below). |
#### `UseGraphViewerOptions`
| Option | Type | Default | Description |
| -------------------------- | --------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `maxNodes` | `number` | `1000` | Maximum nodes held in the LRU buffer. Older nodes are evicted first. |
| `layout` | `LayoutType` | `"forceDirected2d"` | Initial graph layout algorithm. |
| `initialConnectionLimit` | `number` | `100` | **Hard maximum** number of connected instances fetched per expansion (edges + reverse-relation nodes). |
| `whitelistedRelationProps` | `string[]` | all | Property names to follow when extracting direct relations. Strongly recommended for large data models. |
| `coreReverseQueries` | `ReverseRelationQuery[]` | `[]` | Reverse-relation queries to run on node expansion. See shape below. |
| `viewPriorityConfig` | `ViewPriorityConfig` | built-in | Controls which CDF views determine node types. |
| `visualConfig` | `Partial<GraphVisualConfig>` | defaults | Node colors, palette, icon size, path highlight. |
| `themeConfig` | `Partial<GraphThemeConfig>` | defaults | Full reagraph theme overrides. |
| `features` | `Partial<LiteFeatureFlags>` | all enabled | Toggle legend, zoom controls, and node expansion. |
##### `ReverseRelationQuery`
```ts
type ReverseRelationQuery = [
space: string, // space of the view that defines the relation
viewExternalId: string, // external id of the view
viewVersion: string, // view version, e.g. "v1" — required, never assumed
propertyName: string, // direct-relation property pointing back to the expanded node
isList: boolean, // true for list<direct>, false for direct
];
```
Example:
```ts
const coreReverseQueries: ReverseRelationQuery[] = [
["industrial-dm", "Cavity", "v1", "connector", false],
["industrial-dm", "Cable", "v1", "wireGroup", true],
];
```
##### `LiteFeatureFlags`
| Flag | Default | Controls |
| --------------------- | ------- | ----------------------------------------- |
| `enableLegend` | `true` | Node-type color legend overlay |
| `enableZoomControls` | `true` | Zoom in / out / fit buttons |
| `enableNodeExpansion` | `true` | Double-click node to expand its neighbors |
#### `UseGraphViewerReturn`
| Property | Type | Description |
| --------------- | -------------------------------------- | -------------------------------------------------------------- |
| `GraphCanvas` | `React.FC<{ className? }>` | Self-contained canvas component to render. |
| `isLoading` | `boolean` | `true` while data model, seed node, or expansion is in flight. |
| `error` | `string \| null` | Error message, if any. |
| `graphData` | `GraphData` | Current nodes, connections, and node type metadata. |
| `stats` | `GraphStats \| null` | Aggregate counts by node/connection type. |
| `layout` | `LayoutType` | Current layout. |
| `setLayout` | `(layout) => void` | Change the layout algorithm. |
| `selections` | `string[]` | Currently selected node/edge IDs. |
| `setSelections` | `(ids) => void` | Programmatically select nodes/edges. |
| `selectedNode` | `GraphNode \| null` | The selected node object. |
| `selectedEdge` | `GraphEdge \| null` | The selected edge object. |
| `expandNode` | `(nodeId) => Promise<void>` | Fetch and add connected instances for a node. |
| `loadInstance` | `(space, externalId) => Promise<void>` | Load a new seed instance (replaces the graph). |
| `fitView` | `() => void` | Fit all nodes into the viewport. |
| `zoomIn` | `() => void` | Zoom in. |
| `zoomOut` | `() => void` | Zoom out. |
| `clear` | `() => void` | Remove all nodes and edges from the buffer. |
| `graphRef` | `RefObject<GraphCanvasRef>` | Direct ref to the underlying reagraph canvas. |
---
## Layout Options
| ID | Label |
| ----------------- | ----------------- |
| `forceDirected2d` | Force 2D |
| `forceDirected3d` | Force 3D |
| `treeTd2d` | Tree (Top-Down) |
| `treeLr2d` | Tree (Left-Right) |
| `radialOut2d` | Radial |
| `circular2d` | Circular |
---
## Examples
### Minimal embed
```tsx
function GraphPanel() {
const { GraphCanvas } = useGraphViewer({
dataModel: { space: "equipment", externalId: "EquipmentModel", version: "1" },
});
return <GraphCanvas className="h-full w-full" />;
}
```
No seed instance — the canvas renders empty until you call `loadInstance`.
### Layout switcher with stats
```tsx
function GraphWithControls() {
const { GraphCanvas, stats, layout, setLayout } = useGraphViewer({
dataModel: { space: "equipment", externalId: "EquipmentModel", version: "1" },
instance: { space: "assets", externalId: "pump-001" },
});
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-4 border-b p-2">
<select value={layout} onChange={(e) => setLayout(e.target.value as LayoutType)}>
<option value="forceDirected2d">Force 2D</option>
<option value="treeTd2d">Tree</option>
<option value="radialOut2d">Radial</option>
<option value="circular2d">Circular</option>
</select>
{stats && <span>{stats.totalNodes} nodes</span>}
</div>
<GraphCanvas className="flex-1" />
</div>
);
}
```
### Programmatic node loading
```tsx
function SearchAndGraph() {
const { GraphCanvas, loadInstance, isLoading } = useGraphViewer({
dataModel: { space: "equipment", externalId: "EquipmentModel", version: "1" },
});
return (
<div className="flex h-full flex-col">
<input
placeholder="Enter node externalId…"
onKeyDown={(e) => {
if (e.key === "Enter") loadInstance("assets", e.currentTarget.value);
}}
/>
{isLoading && <p>Loading</p>}
<GraphCanvas className="flex-1" />
</div>
);
}
```
### Disable features
```tsx
const { GraphCanvas } = useGraphViewer({
dataModel: { space: "s", externalId: "dm", version: "1" },
instance: { space: "s", externalId: "node-1" },
options: {
features: {
enableLegend: false,
enableZoomControls: false,
enableNodeExpansion: false,
},
},
});
```
### Conservative expansion for large data models
Whitelist relation properties and bound the per-expansion budget to keep CDF
load predictable:
```tsx
const { GraphCanvas } = useGraphViewer({
dataModel: { space: "industrial", externalId: "EWIS", version: "1" },
instance: { space: "instances", externalId: "connector-001" },
options: {
maxNodes: 500,
initialConnectionLimit: 50,
whitelistedRelationProps: ["parent", "child", "connectedTo"],
coreReverseQueries: [
["industrial-dm", "Cavity", "v1", "connector", false],
["industrial-dm", "Cable", "v1", "wireGroup", true],
],
},
});
```
---
## Sizing
`<GraphCanvas>` fills its parent. Give the parent explicit dimensions:
```tsx
<GraphCanvas className="h-[600px] w-full" />
<div className="h-full w-full">
<GraphCanvas className="h-full w-full" />
</div>
<div className="flex h-screen flex-col">
<header></header>
<GraphCanvas className="flex-1" />
</div>
```
---
## Common Patterns
### React to selection
```tsx
const { GraphCanvas, selectedNode } = useGraphViewer({ /* … */ });
useEffect(() => {
if (selectedNode) console.log("Selected:", selectedNode.data.externalId);
}, [selectedNode]);
```
### Expand from an external trigger
```tsx
const { expandNode } = useGraphViewer({ /* … */ });
// nodeId format is "space:externalId"
await expandNode("my-space:pump-001");
```
---
## Architecture
```
graph-viewer/
├── useGraphViewer.tsx # Main hook — composes all sub-hooks, returns GraphCanvas + controls
├── GraphViewerCanvas.tsx # Renders reagraph canvas, zoom controls, and legend
├── GraphViewerLegend.tsx # Color-coded node type legend with click-to-filter
├── ZoomControls.tsx # Zoom in / out / fit-view button group
├── graph-service.ts # CDF API calls — fetchNodeDetails, fetchConnectedNodes
├── graph-config.ts # Theme defaults, icon generation, node/edge transformers
├── useDataModelLoader.ts # Loads data model views from CDF
├── useSeedNode.ts # Loads the initial instance and its connections
├── useNodeBuffer.ts # LRU buffer that caps total nodes at maxNodes
├── useGraphDataPipeline.ts # Transforms raw CDF instances into GraphData + reagraph format
├── useGraphSelection.ts # Tracks selected node/edge state
├── useCanvasResize.ts # Observes container size changes and triggers reagraph resize
├── types.ts # All shared TypeScript types, constants, and helpers
└── index.ts # Public exports
```
---
## Dependencies
| Package | Purpose |
| --------------- | ---------------------------------------------------- |
| `react` | UI framework (peer) |
| `@cognite/sdk` | CDF API client (instances, data models) |
| `@cognite/dune` | Provides the authenticated SDK via `useDune()` |
| `reagraph` | WebGL graph rendering engine |
| `lucide-react` | Icon set used by the node-type legend |
Install latest compatible versions using the target app's package manager. Prefer the React version already pinned by the app rather than upgrading it.
@@ -0,0 +1,111 @@
function ZoomOutIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Zoom out"
>
<circle cx="9" cy="9" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M13.5 13.5L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M6 9H12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function ZoomInIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Zoom in"
>
<circle cx="9" cy="9" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M13.5 13.5L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M9 6V12M6 9H12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function FitViewIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Fit view"
>
<path
d="M2 7V3.5C2 2.67 2.67 2 3.5 2H7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 2H16.5C17.33 2 18 2.67 18 3.5V7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M18 13V16.5C18 17.33 17.33 18 16.5 18H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 18H3.5C2.67 18 2 17.33 2 16.5V13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<rect
x="8"
y="8"
width="4"
height="4"
rx="0.5"
stroke="currentColor"
strokeWidth="1.5"
transform="rotate(0 10 10)"
/>
</svg>
);
}
interface ZoomControlsProps {
onZoomIn: () => void;
onZoomOut: () => void;
onFitView: () => void;
}
export function ZoomControls({ onZoomIn, onZoomOut, onFitView }: ZoomControlsProps) {
const btnClass =
"p-2 text-muted-foreground hover:text-foreground hover:bg-accent dark:hover:bg-accent/50 rounded-lg active:scale-95 transition-colors";
return (
<div className="absolute bottom-4 right-4 flex items-center gap-0.5 bg-background/95 backdrop-blur-sm border border-border rounded-xl shadow-lg p-1.5 z-10">
<button type="button" onClick={onZoomOut} className={btnClass} title="Zoom Out">
<ZoomOutIcon className="w-4 h-4" />
</button>
<button type="button" onClick={onZoomIn} className={btnClass} title="Zoom In">
<ZoomInIcon className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-border mx-1" />
<button type="button" onClick={onFitView} className={btnClass} title="Fit to View">
<FitViewIcon className="w-4 h-4" />
</button>
</div>
);
}
@@ -0,0 +1,271 @@
import { type Theme, lightTheme } from "reagraph";
import {
DEFAULT_NODE_COLOR,
NODE_TYPE_PALETTE,
getIconForType,
type GraphEdge,
type GraphNode,
type GraphThemeConfig,
type GraphVisualConfig,
} from "./types";
// =============================================================================
// Visual Configuration Defaults
// =============================================================================
export const DEFAULT_VISUAL_CONFIG: GraphVisualConfig = {
pathHighlightColor: "#22c55e",
pathHighlightSize: 3,
iconSize: 64,
defaultNodeColor: DEFAULT_NODE_COLOR,
nodeTypePalette: NODE_TYPE_PALETTE,
};
// =============================================================================
// Theme Configuration Defaults
// =============================================================================
export const DEFAULT_THEME_CONFIG: GraphThemeConfig = {
canvas: {
background: "#FAFBFC",
},
node: {
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.3,
label: {
color: "#1E293B",
stroke: "#FFFFFF",
strokeWidth: 3,
activeColor: "#0F172A",
fontSize: 12,
},
},
edge: {
fill: "#94A3B8",
activeFill: "#3B82F6",
opacity: 0.7,
selectedOpacity: 1,
inactiveOpacity: 0.2,
label: {
color: "#64748B",
stroke: "#FFFFFF",
strokeWidth: 2,
activeColor: "#3B82F6",
fontSize: 10,
},
},
ring: {
fill: "#3B82F6",
activeFill: "#2563EB",
},
arrow: {
fill: "#94A3B8",
activeFill: "#3B82F6",
},
cluster: {
stroke: "#E2E8F0",
fill: "#F8FAFC",
label: {
color: "#475569",
},
},
lasso: {
border: "#3B82F6",
background: "rgba(59, 130, 246, 0.1)",
},
};
// =============================================================================
// Icon Paths
// =============================================================================
export const ICON_PATHS: Record<string, string> = {
Plug: "M6 8h12v8H6z M9 8V5 M12 8V5 M15 8V5 M9 16v3 M12 16v3 M15 16v3",
Minus: "M3 12h18 M3 10v4 M21 10v4",
Cable:
"M4 6c3 0 5 3 8 6c3-3 5-6 8-6 M4 12h4c2 0 3 1 4 2c1-1 2-2 4-2h4 M4 18c3 0 5-3 8-6c3 3 5 6 8 6",
CircleDot: "M12 6a6 6 0 0 1 6 6v6H6v-6a6 6 0 0 1 6-6z M12 10v4 M10 12h4",
Zap: "M12 3L6 12h5v6l6-9h-5V3z",
Cpu: "M7 7h10v10H7z M10 7V4 M14 7V4 M10 17v3 M14 17v3 M7 10H4 M7 14H4 M17 10h3 M17 14h3",
ArrowDownToLine: "M12 3v10 M7 13h10 M9 16h6 M11 19h2",
Type: "M6 6h12 M12 6v12 M8 18h8",
LayoutGrid: "M4 4h6v6H4z M14 4h6v6h-6z M4 14h6v6H4z M14 14h6v6h-6z",
GitCommit: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M3 12h5 M16 12h5",
FileText:
"M6 2h8l4 4v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z M14 2v4h4 M8 12h8 M8 16h8",
Activity: "M3 12h4l2-6 3 12 2-6h7",
Box: "M3 8l9-5 9 5v8l-9 5-9-5V8z M12 8v14 M3 8l9 5 9-5",
Wrench: "M14 4l-4 4 6 6 4-4a5 5 0 0 0-6-6z M10 8L4 14l4 4 6-6",
MapPin:
"M12 2a8 8 0 0 0-8 8c0 6 8 12 8 12s8-6 8-12a8 8 0 0 0-8-8z M12 7a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",
Building:
"M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16 M9 7h2 M13 7h2 M9 11h2 M13 11h2 M9 15h6",
AlertTriangle: "M12 3L2 20h20L12 3z M12 9v5 M12 16v2",
Circle: "M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z",
Package: "M3 8l9-5 9 5-9 5-9-5z M3 8v8l9 5V13 M21 8v8l-9 5V13",
ClipboardList: "M8 4h8v2H8V4z M6 6h12v14H6V6z M9 10h6 M9 14h6",
Cog: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M12 4v2 M12 18v2 M5 12H3 M21 12h-2 M6.3 6.3l1.4 1.4 M16.3 16.3l1.4 1.4 M6.3 17.7l1.4-1.4 M16.3 7.7l1.4-1.4",
default: "M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z",
};
// =============================================================================
// Helper Functions
// =============================================================================
export function buildReagraphTheme(config: Partial<GraphThemeConfig> = {}): Theme {
const theme = { ...DEFAULT_THEME_CONFIG, ...config };
return {
...lightTheme,
canvas: {
...lightTheme.canvas,
background: theme.canvas.background,
},
node: {
...lightTheme.node,
opacity: theme.node.opacity,
selectedOpacity: theme.node.selectedOpacity,
inactiveOpacity: theme.node.inactiveOpacity,
label: {
...lightTheme.node.label,
color: theme.node.label.color,
stroke: theme.node.label.stroke,
activeColor: theme.node.label.activeColor,
},
},
edge: {
...lightTheme.edge,
fill: theme.edge.fill,
activeFill: theme.edge.activeFill,
opacity: theme.edge.opacity,
selectedOpacity: theme.edge.selectedOpacity,
inactiveOpacity: theme.edge.inactiveOpacity,
label: {
...lightTheme.edge.label,
color: theme.edge.label.color,
stroke: theme.edge.label.stroke,
activeColor: theme.edge.label.activeColor,
},
},
ring: {
...lightTheme.ring,
fill: theme.ring.fill,
activeFill: theme.ring.activeFill,
},
arrow: {
...lightTheme.arrow,
fill: theme.arrow.fill,
activeFill: theme.arrow.activeFill,
},
cluster: {
...lightTheme.cluster,
stroke: theme.cluster.stroke,
fill: theme.cluster.fill,
label: {
...lightTheme.cluster?.label,
color: theme.cluster.label.color,
},
},
lasso: {
...lightTheme.lasso,
border: theme.lasso.border,
background: theme.lasso.background,
},
};
}
export function mergeVisualConfig(config?: Partial<GraphVisualConfig>): GraphVisualConfig {
return { ...DEFAULT_VISUAL_CONFIG, ...config };
}
// =============================================================================
// Icon Generation
// =============================================================================
const iconUrlCache = new Map<string, string>();
export function getIconUrl(iconName: string, bgColor: string, iconSize = 64): string {
const cacheKey = `${iconName}:${bgColor}:${iconSize}`;
if (iconUrlCache.has(cacheKey)) {
return iconUrlCache.get(cacheKey)!;
}
const pathData = ICON_PATHS[iconName] || ICON_PATHS.default;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 ${iconSize} ${iconSize}">
<circle cx="${iconSize / 2}" cy="${iconSize / 2}" r="${iconSize / 2 - 2}" fill="${bgColor}"/>
<circle cx="${iconSize / 2}" cy="${iconSize / 2}" r="${iconSize / 2 - 4}" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="2"/>
<g transform="translate(${iconSize * 0.1875}, ${iconSize * 0.1875}) scale(${iconSize * 0.026})" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none">
<path d="${pathData}"/>
</g>
</svg>`;
const dataUrl = `data:image/svg+xml;base64,${btoa(svg)}`;
iconUrlCache.set(cacheKey, dataUrl);
return dataUrl;
}
// =============================================================================
// Node/Edge Transformations (Reagraph format)
// =============================================================================
export function transformNodes(
nodes: GraphNode[],
visualConfig: GraphVisualConfig
): Array<{
id: string;
label: string;
fill: string;
icon: string;
data: GraphNode;
}> {
return nodes.map((node) => {
const typeExternalId = node.data?.type?.externalId;
const iconName = getIconForType(typeExternalId);
const fillColor = node.fill || visualConfig.defaultNodeColor;
const iconUrl = getIconUrl(iconName, fillColor, visualConfig.iconSize);
return {
id: node.id,
label: node.label,
fill: fillColor,
icon: iconUrl,
data: node,
};
});
}
export function transformEdges(
connections: GraphEdge[],
highlightedConnectionIds?: Set<string>,
visualConfig?: Partial<GraphVisualConfig>
): Array<{
id: string;
source: string;
target: string;
label?: string;
fill?: string;
size?: number;
data: GraphEdge;
}> {
const config = visualConfig || {
pathHighlightColor: "#22c55e",
pathHighlightSize: 3,
};
return connections.map((edge) => {
const isHighlighted = highlightedConnectionIds?.has(edge.id) ?? false;
return {
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.label,
data: edge,
...(isHighlighted && {
fill: config.pathHighlightColor,
size: config.pathHighlightSize,
}),
};
});
}
@@ -0,0 +1,855 @@
import type { CogniteClient } from "@cognite/sdk";
import type {
CDFEdge,
CDFNode,
DataModelInfo,
GraphData,
ReverseRelationQuery,
ViewPriorityConfig,
} from "./types";
// =============================================================================
// Node Type Detection
// =============================================================================
const DEFAULT_VIEW_TYPE_PRIORITY = [
"ISA95Asset",
"Equipment",
"Area",
"Site",
"Enterprise",
"WorkCell",
"WorkCenter",
"ProcessCell",
"ProcessArea",
"ProductionLine",
"ProductionRun",
"Product",
"WorkOrder",
"WorkUnit",
"QualityAlert",
"FaultCode",
"CogniteAsset",
"CogniteEquipment",
"CogniteFile",
"CogniteTimeSeries",
"CogniteActivity",
"Cognite3DModel",
"CogniteDescribable",
"CogniteSourceable",
"CogniteVisualizable",
"CogniteSchedulable",
];
const DEFAULT_SKIP_VIEWS_FOR_TYPE = new Set([
"CogniteDescribable",
"CogniteSourceable",
"CogniteVisualizable",
"CogniteSchedulable",
"CogniteSourceSystem",
]);
const DEFAULT_SKIP_VIEWS_FOR_PROPERTIES = new Set([
"CogniteSourceable",
"CogniteVisualizable",
"CogniteSchedulable",
]);
function deriveNodeTypeFromProperties(
properties: Record<string, Record<string, unknown>> | undefined,
viewPriorityConfig?: ViewPriorityConfig
): { space: string; externalId: string } | undefined {
if (!properties) return undefined;
const viewTypePriority = viewPriorityConfig?.viewTypePriority ?? DEFAULT_VIEW_TYPE_PRIORITY;
const skipViewsForType = viewPriorityConfig?.skipViewsForType
? new Set(viewPriorityConfig.skipViewsForType)
: DEFAULT_SKIP_VIEWS_FOR_TYPE;
const viewKeys: Array<{
space: string;
externalId: string;
priority: number;
}> = [];
for (const [spaceKey, viewsObj] of Object.entries(properties)) {
if (typeof viewsObj !== "object" || viewsObj === null) continue;
for (const viewKey of Object.keys(viewsObj)) {
const viewExternalId = viewKey.split("/")[0];
if (skipViewsForType.has(viewExternalId)) continue;
const priorityIndex = viewTypePriority.indexOf(viewExternalId);
const priority = priorityIndex >= 0 ? priorityIndex : 100;
viewKeys.push({
space: spaceKey,
externalId: viewExternalId,
priority,
});
}
}
if (viewKeys.length === 0) return undefined;
viewKeys.sort((a, b) => a.priority - b.priority);
return {
space: viewKeys[0].space,
externalId: viewKeys[0].externalId,
};
}
// =============================================================================
// Query with cursor-based pagination
// =============================================================================
const QUERY_PAGE_LIMIT = 10_000;
/**
* Paginate a CDF instances.query selection until either:
* 1. the API has no more cursors,
* 2. the cumulative number of items reaches `maxTotal` (hard cap), or
* 3. an empty page is returned.
*
* `maxTotal` is a HARD MAXIMUM. The function never returns more than `maxTotal`
* items, and it stops fetching additional pages as soon as the cap is reached.
* Pass `Infinity` to disable the cap (legacy "fetch everything" behaviour).
*/
async function queryWithCursorPagination<T>(
client: CogniteClient,
selectionName: string,
query: {
with: Record<
string,
{
nodes?: { filter?: unknown };
edges?: { filter?: unknown };
limit?: number;
}
>;
select: Record<string, { sources?: unknown[]; sort?: unknown[] }>;
includeTyping?: boolean;
},
limitPerPage: number = QUERY_PAGE_LIMIT,
maxTotal: number = Infinity
): Promise<T[]> {
const results: T[] = [];
let cursors: Record<string, string> | undefined;
while (results.length < maxTotal) {
const remaining = maxTotal - results.length;
const pageLimit = Math.max(1, Math.min(limitPerPage, remaining));
const withWithLimit = { ...query.with };
const firstKey = Object.keys(withWithLimit)[0];
if (firstKey && withWithLimit[firstKey]) {
withWithLimit[firstKey] = {
...withWithLimit[firstKey],
limit: pageLimit,
};
}
const request = {
...query,
with: withWithLimit,
cursors,
};
const res = await client.instances.query(
request as Parameters<CogniteClient["instances"]["query"]>[0]
);
const chunk = (res.items[selectionName] ?? []) as T[];
results.push(...chunk);
cursors =
res.nextCursor && Object.keys(res.nextCursor).length > 0
? (res.nextCursor as Record<string, string>)
: undefined;
if (chunk.length === 0 || !cursors?.[selectionName]) break;
}
return results.length > maxTotal ? results.slice(0, maxTotal) : results;
}
// =============================================================================
// fetchNodeDetails
// =============================================================================
export async function fetchNodeDetails(
client: CogniteClient | null,
space: string,
externalId: string
): Promise<CDFNode | null> {
if (!client) {
throw new Error("CDF client is not available");
}
try {
const inspectResult = await client.instances.inspect({
items: [
{
instanceType: "node" as const,
space,
externalId,
},
],
inspectionOperations: {
involvedViews: {
allVersions: false,
},
},
});
const involvedViews = inspectResult.items?.[0]?.inspectionResults?.involvedViews || [];
const sources = involvedViews.slice(0, 10).map((view) => ({
source: {
type: "view" as const,
space: view.space,
externalId: view.externalId,
version: view.version ?? "latest",
},
}));
const response = await client.instances.retrieve({
items: [
{
instanceType: "node" as const,
space,
externalId,
},
],
includeTyping: true,
sources: sources.length > 0 ? sources : undefined,
});
if (response.items.length === 0) {
return null;
}
const item = response.items[0];
const properties = item.properties as Record<string, Record<string, unknown>>;
const derivedType = deriveNodeTypeFromProperties(properties);
return {
space: item.space,
externalId: item.externalId,
instanceType: "node" as const,
version: item.version,
createdTime: item.createdTime,
lastUpdatedTime: item.lastUpdatedTime,
type: item.type || derivedType,
properties,
};
} catch (error) {
console.error("[GraphViewer] Error fetching node details:", error);
return null;
}
}
// =============================================================================
// fetchConnectedNodes
// =============================================================================
export interface ExpandNodeResult {
newNodes: CDFNode[];
newEdges: CDFEdge[];
connectedNodeIds: string[];
}
export async function fetchConnectedNodes(
client: CogniteClient | null,
nodeSpace: string,
nodeExternalId: string,
existingNodeIds: Set<string>,
_dataModel?: DataModelInfo,
limit = 100,
whitelistedRelationProps?: Set<string>,
coreReverseQueries?: ReverseRelationQuery[],
viewPriorityConfig?: ViewPriorityConfig
): Promise<ExpandNodeResult> {
if (!client) {
throw new Error("CDF client is not available. Please ensure you are authenticated.");
}
const extractDirectRelations = (
properties: Record<string, Record<string, unknown>> | undefined
): Array<{ space: string; externalId: string }> => {
if (!properties) return [];
const refs: Array<{ space: string; externalId: string }> = [];
const seen = new Set<string>();
const processValue = (val: unknown) => {
if (val && typeof val === "object" && "space" in val && "externalId" in val) {
const ref = val as { space: string; externalId: string };
if (typeof ref.space === "string" && typeof ref.externalId === "string") {
const key = `${ref.space}:${ref.externalId}`;
if (!seen.has(key)) {
seen.add(key);
refs.push({ space: ref.space, externalId: ref.externalId });
}
}
} else if (Array.isArray(val)) {
val.forEach(processValue);
}
};
for (const spaceObj of Object.values(properties)) {
if (typeof spaceObj !== "object" || spaceObj === null) continue;
for (const viewObj of Object.values(spaceObj)) {
if (typeof viewObj !== "object" || viewObj === null) continue;
for (const [propName, propVal] of Object.entries(viewObj as Record<string, unknown>)) {
if (!whitelistedRelationProps || whitelistedRelationProps.has(propName)) {
processValue(propVal);
}
}
}
}
return refs;
};
const [sourceNodeRefs, edgeResponse] = await Promise.all([
(async () => {
try {
const sourceInspect = await client.instances.inspect({
items: [
{
instanceType: "node" as const,
space: nodeSpace,
externalId: nodeExternalId,
},
],
inspectionOperations: { involvedViews: { allVersions: false } },
});
if (sourceInspect.items.length > 0) {
const involvedViews =
(
sourceInspect.items[0] as {
inspectionResults?: {
involvedViews?: Array<{
space: string;
externalId: string;
version: string;
}>;
};
}
).inspectionResults?.involvedViews || [];
const sources = involvedViews.slice(0, 10).map((v) => ({
source: {
type: "view" as const,
space: v.space,
externalId: v.externalId,
version: v.version,
},
}));
if (sources.length > 0) {
const sourceNodeResponse = await client.instances.retrieve({
items: [
{
instanceType: "node" as const,
space: nodeSpace,
externalId: nodeExternalId,
},
],
sources,
includeTyping: true,
});
if (sourceNodeResponse.items.length > 0) {
const props = sourceNodeResponse.items[0].properties as Record<
string,
Record<string, unknown>
>;
return extractDirectRelations(props);
}
}
}
return [];
} catch {
return [];
}
})(),
(async () => {
type EdgeItem = {
space: string;
externalId: string;
version: number;
createdTime: number;
lastUpdatedTime: number;
type: { space: string; externalId: string };
startNode: { space: string; externalId: string };
endNode: { space: string; externalId: string };
properties?: Record<string, Record<string, unknown>>;
};
const items = await queryWithCursorPagination<EdgeItem>(
client,
"edges",
{
with: {
edges: {
edges: {
filter: {
or: [
{
equals: {
property: ["edge", "startNode"],
value: { space: nodeSpace, externalId: nodeExternalId },
},
},
{
equals: {
property: ["edge", "endNode"],
value: { space: nodeSpace, externalId: nodeExternalId },
},
},
],
},
},
},
},
select: { edges: {} },
includeTyping: true,
},
Math.min(limit * 2, QUERY_PAGE_LIMIT),
// Hard cap: never return more than `limit` edges per expansion. This
// upper bound protects the consumer from runaway pagination and matches
// the documented contract of `initialConnectionLimit`.
limit
);
return { items };
})(),
]);
const newEdges: CDFEdge[] = edgeResponse.items.map((edgeItem) => {
const item = edgeItem as {
space: string;
externalId: string;
version: number;
createdTime: number;
lastUpdatedTime: number;
type: { space: string; externalId: string };
startNode: { space: string; externalId: string };
endNode: { space: string; externalId: string };
properties?: Record<string, Record<string, unknown>>;
};
return {
space: item.space,
externalId: item.externalId,
instanceType: "edge" as const,
version: item.version,
createdTime: item.createdTime,
lastUpdatedTime: item.lastUpdatedTime,
type: item.type,
startNode: item.startNode,
endNode: item.endNode,
properties: item.properties,
};
});
const connectedNodeRefs = new Map<string, { space: string; externalId: string }>();
for (const edge of newEdges) {
const startKey = `${edge.startNode.space}:${edge.startNode.externalId}`;
const endKey = `${edge.endNode.space}:${edge.endNode.externalId}`;
if (!existingNodeIds.has(startKey)) {
connectedNodeRefs.set(startKey, edge.startNode);
}
if (!existingNodeIds.has(endKey)) {
connectedNodeRefs.set(endKey, edge.endNode);
}
}
const sourceNodeKey = `${nodeSpace}:${nodeExternalId}`;
const directRelationEdges: CDFEdge[] = [];
for (const ref of sourceNodeRefs) {
const refKey = `${ref.space}:${ref.externalId}`;
if (
refKey !== sourceNodeKey &&
!existingNodeIds.has(refKey) &&
!connectedNodeRefs.has(refKey)
) {
connectedNodeRefs.set(refKey, ref);
directRelationEdges.push({
space: nodeSpace,
externalId: `synthetic:${nodeExternalId}->direct->${ref.externalId}`,
instanceType: "edge" as const,
version: 1,
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
type: { space: "synthetic", externalId: "direct-relation" },
startNode: { space: nodeSpace, externalId: nodeExternalId },
endNode: { space: ref.space, externalId: ref.externalId },
properties: {},
});
}
}
newEdges.push(...directRelationEdges);
// Reverse relation queries
const reverseRelationRefs: Array<{ space: string; externalId: string }> = [];
const CORE_REVERSE_QUERIES = coreReverseQueries ?? [];
try {
const nodeRef = { space: nodeSpace, externalId: nodeExternalId };
// Spread the per-expansion budget across all configured reverse queries so
// the total number of nodes contributed by reverse relations stays within
// `limit`. Each query gets at least 1 row.
const perQueryLimit =
CORE_REVERSE_QUERIES.length > 0
? Math.max(1, Math.ceil(limit / CORE_REVERSE_QUERIES.length))
: limit;
const queryPromises = CORE_REVERSE_QUERIES.map(
async ([viewSpace, viewExternalId, viewVersion, propertyName, isList]) => {
try {
const propertyPath = [
viewSpace,
`${viewExternalId}/${viewVersion}`,
propertyName,
];
const filter = isList
? {
containsAny: {
property: propertyPath,
values: [nodeRef],
},
}
: {
equals: {
property: propertyPath,
value: nodeRef,
},
};
const items = await queryWithCursorPagination<{
space: string;
externalId: string;
}>(
client,
"nodes",
{
with: {
nodes: {
nodes: { filter },
},
},
select: { nodes: {} },
includeTyping: false,
},
Math.min(50, perQueryLimit),
perQueryLimit
);
return items.map((item) => ({
space: item.space,
externalId: item.externalId,
}));
} catch {
return [];
}
}
);
const results = await Promise.all(queryPromises);
for (const refs of results) {
reverseRelationRefs.push(...refs);
}
} catch {
// Silently ignore reverse relation query failures
}
const syntheticEdges: CDFEdge[] = [];
for (const ref of reverseRelationRefs) {
const refKey = `${ref.space}:${ref.externalId}`;
if (
refKey !== sourceNodeKey &&
!existingNodeIds.has(refKey) &&
!connectedNodeRefs.has(refKey)
) {
connectedNodeRefs.set(refKey, ref);
syntheticEdges.push({
space: ref.space,
externalId: `synthetic:${ref.externalId}->assets->${nodeExternalId}`,
instanceType: "edge" as const,
version: 1,
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
type: { space: "cdf_cdm", externalId: "references-asset" },
startNode: { space: ref.space, externalId: ref.externalId },
endNode: { space: nodeSpace, externalId: nodeExternalId },
properties: {},
});
}
}
newEdges.push(...syntheticEdges);
const connectedNodeIds = newEdges.flatMap((edge) => [
`${edge.startNode.space}:${edge.startNode.externalId}`,
`${edge.endNode.space}:${edge.endNode.externalId}`,
]);
const nodesToFetch = Array.from(connectedNodeRefs.values()).slice(0, limit);
let newNodes: CDFNode[] = [];
if (nodesToFetch.length > 0) {
const inspectResponse = await client.instances.inspect({
items: nodesToFetch.map((ref) => ({
instanceType: "node" as const,
space: ref.space,
externalId: ref.externalId,
})),
inspectionOperations: { involvedViews: {} },
});
const nodeTypeMap = new Map<string, { space: string; externalId: string }>();
const allViews = new Map<string, { space: string; externalId: string; version: string }>();
for (const inspectItem of inspectResponse.items) {
const item = inspectItem as {
space: string;
externalId: string;
inspectionResults?: {
involvedViews?: Array<{
space: string;
externalId: string;
version: string;
}>;
};
};
const key = `${item.space}:${item.externalId}`;
const views = item.inspectionResults?.involvedViews || [];
const skipViewsForProperties = viewPriorityConfig?.skipViewsForProperties
? new Set(viewPriorityConfig.skipViewsForProperties)
: DEFAULT_SKIP_VIEWS_FOR_PROPERTIES;
const skipViewsForType = viewPriorityConfig?.skipViewsForType
? new Set(viewPriorityConfig.skipViewsForType)
: DEFAULT_SKIP_VIEWS_FOR_TYPE;
for (const view of views) {
if (!skipViewsForProperties.has(view.externalId)) {
const viewKey = `${view.space}:${view.externalId}`;
if (!allViews.has(viewKey)) {
allViews.set(viewKey, view);
}
}
}
const domainView = views.find(
(v) => !v.space.startsWith("cdf_cdm") && !skipViewsForType.has(v.externalId)
);
const cdmView = views.find(
(v) => v.space.startsWith("cdf_cdm") && !skipViewsForType.has(v.externalId)
);
const bestView = domainView || cdmView;
if (bestView) {
nodeTypeMap.set(key, {
space: bestView.space,
externalId: bestView.externalId,
});
}
}
const sources = [
{
source: {
type: "view" as const,
space: "cdf_cdm",
externalId: "CogniteDescribable",
version: "v1",
},
},
...Array.from(allViews.values()).map((v) => ({
source: {
type: "view" as const,
space: v.space,
externalId: v.externalId,
version: v.version,
},
})),
];
const retrieveItems = nodesToFetch.map((ref) => ({
instanceType: "node" as const,
space: ref.space,
externalId: ref.externalId,
}));
let nodeResponse: Awaited<ReturnType<CogniteClient["instances"]["retrieve"]>>;
try {
nodeResponse = await client.instances.retrieve({
items: retrieveItems,
includeTyping: true,
});
} catch {
nodeResponse = { items: [] };
}
const propertiesMap = new Map<string, Record<string, Record<string, unknown>>>();
if (sources.length > 0 && nodeResponse.items.length > 0) {
try {
const propsResponse = await client.instances.retrieve({
items: retrieveItems,
sources,
includeTyping: true,
});
for (const item of propsResponse.items) {
const key = `${item.space}:${item.externalId}`;
if (item.properties) {
propertiesMap.set(key, item.properties as Record<string, Record<string, unknown>>);
}
}
} catch {
// Continue without additional properties
}
}
const nodesWithoutProps = retrieveItems.filter((item) => {
const key = `${item.space}:${item.externalId}`;
return !propertiesMap.has(key);
});
if (nodesWithoutProps.length > 0) {
const individualFetches = nodesWithoutProps.map(async (nodeRef) => {
const key = `${nodeRef.space}:${nodeRef.externalId}`;
try {
const inspectResult = await client.instances.inspect({
items: [nodeRef],
inspectionOperations: { involvedViews: { allVersions: false } },
});
const views =
(
inspectResult.items?.[0] as {
inspectionResults?: {
involvedViews?: Array<{
space: string;
externalId: string;
version: string;
}>;
};
}
)?.inspectionResults?.involvedViews || [];
if (views.length > 0) {
const nodeSources = views.slice(0, 10).map((v) => ({
source: {
type: "view" as const,
space: v.space,
externalId: v.externalId,
version: v.version,
},
}));
const propsResp = await client.instances.retrieve({
items: [nodeRef],
sources: nodeSources,
includeTyping: true,
});
if (propsResp.items.length > 0 && propsResp.items[0].properties) {
propertiesMap.set(
key,
propsResp.items[0].properties as Record<string, Record<string, unknown>>
);
}
}
} catch {
// Silently ignore individual fetch failures
}
});
await Promise.all(individualFetches);
}
newNodes = nodeResponse.items
.filter(
(item): item is typeof item & { instanceType: "node" } =>
(item as { instanceType?: string }).instanceType === "node" || !("instanceType" in item)
)
.map((item) => {
const key = `${item.space}:${item.externalId}`;
const inspectType = nodeTypeMap.get(key);
const mergedProps =
propertiesMap.get(key) || (item.properties as Record<string, Record<string, unknown>>);
const derivedType =
deriveNodeTypeFromProperties(mergedProps, viewPriorityConfig) || inspectType;
return {
space: item.space,
externalId: item.externalId,
instanceType: "node" as const,
version: item.version,
createdTime: item.createdTime,
lastUpdatedTime: item.lastUpdatedTime,
type: item.type || derivedType,
properties: mergedProps,
};
});
}
return { newNodes, newEdges, connectedNodeIds };
}
// =============================================================================
// Graph utility functions (pure, no API calls)
// =============================================================================
export function getGraphStats(graphData: GraphData) {
const nodeTypes = new Map<string, number>();
const connectionTypes = new Map<string, number>();
graphData.nodes.forEach((node) => {
const type = node.data.type?.externalId || "Unknown";
nodeTypes.set(type, (nodeTypes.get(type) || 0) + 1);
});
graphData.connections.forEach((connection) => {
const type = connection.data.type.externalId;
connectionTypes.set(type, (connectionTypes.get(type) || 0) + 1);
});
return {
totalNodes: graphData.nodes.length,
totalConnections: graphData.connections.length,
nodeTypes: Object.fromEntries(nodeTypes),
connectionTypes: Object.fromEntries(connectionTypes),
};
}
export function getConnectedNodes(graphData: GraphData, nodeId: string) {
const connectedNodeIds = new Set<string>();
graphData.connections.forEach((connection) => {
if (connection.source === nodeId) {
connectedNodeIds.add(connection.target);
}
if (connection.target === nodeId) {
connectedNodeIds.add(connection.source);
}
});
return graphData.nodes.filter((node) => connectedNodeIds.has(node.id));
}
export function getNodeEdges(graphData: GraphData, nodeId: string) {
return graphData.connections.filter(
(connection) => connection.source === nodeId || connection.target === nodeId
);
}
+21
View File
@@ -0,0 +1,21 @@
export { useGraphViewer } from "./useGraphViewer";
export type {
UseGraphViewerConfig,
UseGraphViewerOptions,
UseGraphViewerReturn,
LiteFeatureFlags,
GraphStats,
LayoutType,
GraphData,
GraphNode,
GraphEdge,
NodeTypeInfo,
CDFNode,
CDFEdge,
DataModelInfo,
ReverseRelationQuery,
ViewReference,
ViewPriorityConfig,
GraphThemeConfig,
GraphVisualConfig,
} from "./types";
+431
View File
@@ -0,0 +1,431 @@
import type { GraphCanvasRef } from "reagraph";
// =============================================================================
// Layout
// =============================================================================
export type LayoutType =
| "forceDirected2d"
| "forceDirected3d"
| "treeTd2d"
| "treeLr2d"
| "radialOut2d"
| "circular2d";
export interface LayoutOption {
id: LayoutType;
label: string;
}
export const LAYOUT_OPTIONS: LayoutOption[] = [
{ id: "forceDirected2d", label: "Force 2D" },
{ id: "forceDirected3d", label: "Force 3D" },
{ id: "treeTd2d", label: "Tree (Top-Down)" },
{ id: "treeLr2d", label: "Tree (Left-Right)" },
{ id: "radialOut2d", label: "Radial" },
{ id: "circular2d", label: "Circular" },
];
// =============================================================================
// CDF Data Model Types
// =============================================================================
export interface ViewReference {
space: string;
externalId: string;
version?: string;
}
export interface DataModelInfo {
space: string;
externalId: string;
name?: string;
description?: string;
version?: string;
views: ViewReference[];
}
/**
* Tuple describing a reverse-relation query to run on node expansion.
*
* `[space, viewExternalId, viewVersion, propertyName, isList]`
*
* - `space` - space of the view that defines the relation.
* - `viewExternalId` - external id of the view that defines the relation.
* - `viewVersion` - version of the view (e.g. `"v1"`, `"1"`). Required so the
* lookup is not pinned to any specific version.
* - `propertyName` - direct relation property on the view that points back to
* the node being expanded.
* - `isList` - `true` when the relation is `list<direct>`, otherwise `false`.
*/
export type ReverseRelationQuery = [
space: string,
viewExternalId: string,
viewVersion: string,
propertyName: string,
isList: boolean,
];
export interface ViewPriorityConfig {
viewTypePriority?: string[];
priorityViewNames?: string[];
skipViewsForType?: string[];
skipViewsForProperties?: string[];
}
// =============================================================================
// CDF Instance Types
// =============================================================================
export interface CDFNode {
instanceType: "node";
space: string;
externalId: string;
version?: number;
createdTime?: number;
lastUpdatedTime?: number;
type?: { space: string; externalId: string };
properties?: Record<string, unknown>;
}
export interface CDFEdge {
instanceType: "edge";
space: string;
externalId: string;
version?: number;
createdTime?: number;
lastUpdatedTime?: number;
type: { space: string; externalId: string };
startNode: { space: string; externalId: string };
endNode: { space: string; externalId: string };
properties?: Record<string, unknown>;
}
// =============================================================================
// Graph Data Types
// =============================================================================
export interface GraphNode {
id: string;
label: string;
fill?: string;
data: CDFNode;
}
export interface GraphEdge {
id: string;
source: string;
target: string;
label?: string;
fill?: string;
size?: number;
data: CDFEdge;
}
export interface NodeTypeInfo {
externalId: string;
space: string;
color: string;
count: number;
}
export interface GraphData {
nodes: GraphNode[];
connections: GraphEdge[];
nodeTypes: NodeTypeInfo[];
}
export interface GraphSelection {
type: "node" | "edge" | null;
id: string | null;
node?: GraphNode;
edge?: GraphEdge;
}
// =============================================================================
// Visual / Theme Configuration
// =============================================================================
export interface GraphThemeConfig {
canvas: {
background: string;
};
node: {
opacity: number;
selectedOpacity: number;
inactiveOpacity: number;
label: {
color: string;
stroke: string;
strokeWidth: number;
activeColor: string;
fontSize: number;
};
};
edge: {
fill: string;
activeFill: string;
opacity: number;
selectedOpacity: number;
inactiveOpacity: number;
label: {
color: string;
stroke: string;
strokeWidth: number;
activeColor: string;
fontSize: number;
};
};
ring: {
fill: string;
activeFill: string;
};
arrow: {
fill: string;
activeFill: string;
};
cluster: {
stroke: string;
fill: string;
label: {
color: string;
};
};
lasso: {
border: string;
background: string;
};
}
export interface GraphVisualConfig {
pathHighlightColor: string;
pathHighlightSize: number;
iconSize: number;
defaultNodeColor: string;
nodeTypePalette: string[];
}
// =============================================================================
// Node Color Palette & Icons
// =============================================================================
export const NODE_TYPE_PALETTE = [
"#3b82f6",
"#22c55e",
"#ef4444",
"#a855f7",
"#f59e0b",
"#06b6d4",
"#e11d48",
"#0ea5e9",
"#8b5cf6",
"#f97316",
"#14b8a6",
"#f43f5e",
];
export const DEFAULT_NODE_COLOR = "#94a3b8";
export const NODE_TYPE_ICONS: Record<string, string> = {
Connector: "Plug",
Wire: "Minus",
Cable: "Cable",
Cavity: "CircleDot",
Shunt: "Zap",
ShuntCollection: "LayoutGrid",
GroundReference: "ArrowDownToLine",
HardwareOccurence: "Cpu",
TextElement: "Type",
WireExtermity: "GitCommitHorizontal",
CogniteFile: "FileText",
CogniteFileCategory: "FolderOpen",
CogniteTimeSeries: "Activity",
CogniteDatapoint: "TrendingUp",
CogniteAsset: "Box",
CogniteEquipment: "Wrench",
CogniteEquipmentType: "Settings",
CogniteAssetClass: "Layers",
CogniteAssetType: "Tag",
ISA95Asset: "Factory",
Enterprise: "Building2",
Site: "Building",
Area: "MapPin",
ProcessCell: "Grid3X3",
ProcessArea: "LayoutGrid",
ProductionLine: "ArrowRightLeft",
ProductionUnit: "Cpu",
Equipment: "Cog",
EquipmentModule: "CircuitBoard",
WorkCell: "Workflow",
WorkCenter: "Server",
WorkUnit: "Puzzle",
WorkOrder: "ClipboardList",
CogniteActivity: "Calendar",
MaintenanceOrder: "Hammer",
FaultCode: "AlertTriangle",
QualityAlert: "ShieldAlert",
Product: "Package",
ProductComponent: "Component",
ProductNode: "Boxes",
Batch: "Beaker",
Cognite3DModel: "Box",
CogniteCADModel: "Box",
CogniteCADNode: "Shapes",
Cognite360Image: "Image",
CognitePointCloudModel: "Scan",
CogniteAnnotation: "MessageSquare",
CogniteDiagramAnnotation: "StickyNote",
CogniteSourceSystem: "Database",
default: "Circle",
};
export function getIconForType(typeExternalId: string | undefined): string {
if (!typeExternalId) return NODE_TYPE_ICONS.default;
if (NODE_TYPE_ICONS[typeExternalId]) {
return NODE_TYPE_ICONS[typeExternalId];
}
const lower = typeExternalId.toLowerCase();
if (lower.includes("connector") || lower.includes("plug")) return "Plug";
if (lower.includes("wire")) return "Minus";
if (lower.includes("cable")) return "Cable";
if (lower.includes("cavity")) return "CircleDot";
if (lower.includes("shunt") && lower.includes("collection")) return "LayoutGrid";
if (lower.includes("shunt")) return "Zap";
if (lower.includes("ground")) return "ArrowDownToLine";
if (lower.includes("hardware")) return "Cpu";
if (lower.includes("file") || lower.includes("document")) return "FileText";
if (lower.includes("timeseries") || lower.includes("series")) return "Activity";
if (lower.includes("asset")) return "Box";
if (lower.includes("equipment")) return "Wrench";
if (lower.includes("work") || lower.includes("order") || lower.includes("maintenance"))
return "ClipboardList";
if (lower.includes("product")) return "Package";
if (lower.includes("area") || lower.includes("location")) return "MapPin";
if (lower.includes("site") || lower.includes("building")) return "Building";
if (lower.includes("3d") || lower.includes("model") || lower.includes("cad")) return "Box";
if (lower.includes("image") || lower.includes("photo")) return "Image";
if (lower.includes("batch")) return "Beaker";
if (lower.includes("alert") || lower.includes("fault")) return "AlertTriangle";
return NODE_TYPE_ICONS.default;
}
// =============================================================================
// Instance ID Helpers
// =============================================================================
export function createInstanceId(space: string, externalId: string) {
return `${space}:${externalId}`;
}
export function parseInstanceId(id: string) {
const [space, ...rest] = id.split(":");
return { space, externalId: rest.join(":") };
}
// =============================================================================
// Node Label
// =============================================================================
function findNameInObject(obj: Record<string, unknown> | unknown): string | undefined {
if (!obj || typeof obj !== "object") return undefined;
const objRecord = obj as Record<string, unknown>;
if (typeof objRecord.name === "string" && objRecord.name.trim().length > 0) return objRecord.name;
for (const value of Object.values(objRecord)) {
if (value && typeof value === "object") {
const nested = findNameInObject(value);
if (nested) return nested;
}
}
return undefined;
}
export function getNodeLabel(node: CDFNode): string {
if (node.properties && typeof node.properties === "object") {
for (const viewObj of Object.values(node.properties)) {
if (viewObj && typeof viewObj === "object") {
const maybe = findNameInObject(viewObj);
if (maybe) return maybe;
}
}
}
if (node.type?.externalId) {
return node.type.externalId;
}
return node.externalId;
}
// =============================================================================
// Lite Hook API Types
// =============================================================================
/**
* CDF-friendly input configuration for the `useGraphViewer` hook.
*/
export interface UseGraphViewerConfig {
dataModel: {
space: string;
externalId: string;
version: string;
};
instance?: {
space: string;
externalId: string;
};
options?: UseGraphViewerOptions;
}
export interface UseGraphViewerOptions {
maxNodes?: number;
layout?: LayoutType;
whitelistedRelationProps?: string[];
coreReverseQueries?: ReverseRelationQuery[];
viewPriorityConfig?: ViewPriorityConfig;
initialConnectionLimit?: number;
visualConfig?: Partial<GraphVisualConfig>;
themeConfig?: Partial<GraphThemeConfig>;
features?: Partial<LiteFeatureFlags>;
}
export interface LiteFeatureFlags {
enableLegend: boolean;
enableZoomControls: boolean;
enableNodeExpansion: boolean;
}
export const DEFAULT_LITE_FEATURES: LiteFeatureFlags = {
enableLegend: true,
enableZoomControls: true,
enableNodeExpansion: true,
};
export interface GraphStats {
totalNodes: number;
totalConnections: number;
nodeTypes: Record<string, number>;
connectionTypes: Record<string, number>;
}
export interface UseGraphViewerReturn {
GraphCanvas: React.FC<{ className?: string }>;
isLoading: boolean;
error: string | null;
graphData: GraphData;
stats: GraphStats | null;
layout: LayoutType;
setLayout: (layout: LayoutType) => void;
selections: string[];
setSelections: (ids: string[]) => void;
selectedNode: GraphNode | null;
selectedEdge: GraphEdge | null;
expandNode: (nodeId: string) => Promise<void>;
loadInstance: (space: string, externalId: string) => Promise<void>;
fitView: () => void;
zoomIn: () => void;
zoomOut: () => void;
clear: () => void;
graphRef: React.RefObject<GraphCanvasRef | null>;
}
@@ -0,0 +1,30 @@
import { type RefObject, useEffect } from "react";
import type { GraphCanvasRef } from "reagraph";
export function useCanvasResize(
containerRef: RefObject<HTMLDivElement | null>,
graphRef: RefObject<GraphCanvasRef | null>
) {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const triggerResize = () => {
if (graphRef.current) {
requestAnimationFrame(() => {
window.dispatchEvent(new Event("resize"));
});
}
};
const resizeObserver = new ResizeObserver(triggerResize);
resizeObserver.observe(container);
const timeoutId = setTimeout(triggerResize, 100);
return () => {
resizeObserver.disconnect();
clearTimeout(timeoutId);
};
}, [containerRef, graphRef]);
}
@@ -0,0 +1,90 @@
import { useDune } from "@cognite/dune";
import { useEffect, useState } from "react";
import type { DataModelInfo, ViewReference } from "./types";
interface UseDataModelLoaderConfig {
space: string;
externalId: string;
version: string;
}
interface UseDataModelLoaderReturn {
dataModel: DataModelInfo | null;
isLoading: boolean;
error: string | null;
}
export function useDataModelLoader(
config: UseDataModelLoaderConfig
): UseDataModelLoaderReturn {
const { sdk, isLoading: isAuthLoading } = useDune();
const [dataModel, setDataModel] = useState<DataModelInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!sdk || isAuthLoading) return;
let cancelled = false;
async function load() {
try {
setIsLoading(true);
setError(null);
const response = await sdk.dataModels.retrieve([
{
space: config.space,
externalId: config.externalId,
version: config.version,
},
]);
if (cancelled) return;
if (response.items.length === 0) {
throw new Error(
`Data model not found: ${config.space}/${config.externalId} v${config.version}`
);
}
const model = response.items[0];
const views: ViewReference[] = (model.views || []).map(
(v: { space: string; externalId: string; version: string }) => ({
space: v.space,
externalId: v.externalId,
version: v.version,
})
);
setDataModel({
space: model.space,
externalId: model.externalId,
name: model.name,
description: model.description,
version: model.version,
views,
});
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load data model"
);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, [sdk, isAuthLoading, config.space, config.externalId, config.version]);
return { dataModel, isLoading, error };
}
@@ -0,0 +1,101 @@
import { useMemo } from "react";
import type {
CDFEdge,
CDFNode,
GraphData,
GraphEdge,
GraphNode,
GraphVisualConfig,
NodeTypeInfo,
} from "./types";
import { createInstanceId, getNodeLabel } from "./types";
import { getGraphStats } from "./graph-service";
import { transformEdges, transformNodes } from "./graph-config";
interface UseGraphDataPipelineParams {
bufferNodes: CDFNode[];
bufferConnections: CDFEdge[];
visualConfig: GraphVisualConfig;
}
export function useGraphDataPipeline({
bufferNodes,
bufferConnections,
visualConfig,
}: UseGraphDataPipelineParams) {
const graphData: GraphData = useMemo(() => {
const typeMap = new Map<string, NodeTypeInfo>();
const colorMap = new Map<string, string>();
let colorIndex = 0;
const graphNodes: GraphNode[] = bufferNodes.map((node) => {
const typeKey = node.type ? `${node.type.space}:${node.type.externalId}` : "unknown";
if (node.type) {
if (!typeMap.has(typeKey)) {
const color =
visualConfig.nodeTypePalette[colorIndex % visualConfig.nodeTypePalette.length] ||
visualConfig.defaultNodeColor;
typeMap.set(typeKey, {
externalId: node.type.externalId,
space: node.type.space,
color,
count: 1,
});
colorMap.set(typeKey, color);
colorIndex += 1;
} else {
const existing = typeMap.get(typeKey);
if (existing) existing.count += 1;
}
}
const fill = node.type
? (colorMap.get(typeKey) ?? visualConfig.defaultNodeColor)
: visualConfig.defaultNodeColor;
return {
id: createInstanceId(node.space, node.externalId),
label: getNodeLabel(node),
fill,
data: node,
};
});
const graphConnections: GraphEdge[] = bufferConnections.map((edge) => ({
id: createInstanceId(edge.space, edge.externalId),
source: createInstanceId(edge.startNode.space, edge.startNode.externalId),
target: createInstanceId(edge.endNode.space, edge.endNode.externalId),
label: edge.type?.externalId || "",
data: edge,
}));
return {
nodes: graphNodes,
connections: graphConnections,
nodeTypes: Array.from(typeMap.values()),
};
}, [bufferNodes, bufferConnections, visualConfig]);
const reagraphNodes = useMemo(
() => transformNodes(graphData.nodes, visualConfig),
[graphData.nodes, visualConfig]
);
const emptyHighlights = useMemo(() => new Set<string>(), []);
const reagraphEdges = useMemo(
() => transformEdges(graphData.connections, emptyHighlights, visualConfig),
[graphData.connections, emptyHighlights, visualConfig]
);
const displayedStats = useMemo(() => getGraphStats(graphData), [graphData]);
return {
graphData,
displayedGraphData: graphData,
reagraphNodes,
reagraphEdges,
displayedStats,
};
}
@@ -0,0 +1,98 @@
import { useCallback, useMemo, useState } from "react";
import type { GraphEdge, GraphNode, GraphSelection } from "./types";
export interface UseGraphSelectionReturn {
selection: GraphSelection;
selectedNode: GraphNode | null;
selectedEdge: GraphEdge | null;
selectNode: (node: GraphNode | null) => void;
selectEdge: (edge: GraphEdge | null) => void;
clearSelection: () => void;
isNodeSelected: (nodeId: string) => boolean;
isEdgeSelected: (edgeId: string) => boolean;
}
export function useGraphSelection(): UseGraphSelectionReturn {
const [selection, setSelection] = useState<GraphSelection>({
type: null,
id: null,
node: undefined,
edge: undefined,
});
const selectNode = useCallback((node: GraphNode | null) => {
if (node) {
setSelection({
type: "node",
id: node.id,
node,
edge: undefined,
});
} else {
setSelection({
type: null,
id: null,
node: undefined,
edge: undefined,
});
}
}, []);
const selectEdge = useCallback((edge: GraphEdge | null) => {
if (edge) {
setSelection({
type: "edge",
id: edge.id,
node: undefined,
edge,
});
} else {
setSelection({
type: null,
id: null,
node: undefined,
edge: undefined,
});
}
}, []);
const clearSelection = useCallback(() => {
setSelection({
type: null,
id: null,
node: undefined,
edge: undefined,
});
}, []);
const isNodeSelected = useCallback(
(nodeId: string) => selection.type === "node" && selection.id === nodeId,
[selection]
);
const isEdgeSelected = useCallback(
(edgeId: string) => selection.type === "edge" && selection.id === edgeId,
[selection]
);
const selectedNode = useMemo(
() => (selection.type === "node" ? (selection.node ?? null) : null),
[selection]
);
const selectedEdge = useMemo(
() => (selection.type === "edge" ? (selection.edge ?? null) : null),
[selection]
);
return {
selection,
selectedNode,
selectedEdge,
selectNode,
selectEdge,
clearSelection,
isNodeSelected,
isEdgeSelected,
};
}
@@ -0,0 +1,288 @@
import { useDune } from "@cognite/dune";
import { useCallback, useMemo, useRef, useState } from "react";
import type { GraphCanvasRef } from "reagraph";
import { useGraphSelection } from "./useGraphSelection";
import { useNodeBuffer } from "./useNodeBuffer";
import { useGraphDataPipeline } from "./useGraphDataPipeline";
import { buildReagraphTheme, mergeVisualConfig } from "./graph-config";
import { fetchConnectedNodes } from "./graph-service";
import type { GraphNode, GraphEdge, LayoutType } from "./types";
import { createInstanceId, parseInstanceId } from "./types";
import { useDataModelLoader } from "./useDataModelLoader";
import { useSeedNode } from "./useSeedNode";
import { GraphViewerCanvas } from "./GraphViewerCanvas";
import {
DEFAULT_LITE_FEATURES,
type LiteFeatureFlags,
type UseGraphViewerConfig,
type UseGraphViewerReturn,
} from "./types";
/**
* `useGraphViewer` -- the single entry point for embedding a CDF graph viewer.
*
* Returns a self-contained `<GraphCanvas>` component plus state and controls.
*
* @example
* ```tsx
* const { GraphCanvas, isLoading, error } = useGraphViewer({
* dataModel: { space: "my-space", externalId: "my-dm", version: "1" },
* instance: { space: "my-inst-space", externalId: "pump-001" },
* });
*
* return <GraphCanvas className="h-full w-full" />;
* ```
*/
export function useGraphViewer(config: UseGraphViewerConfig): UseGraphViewerReturn {
const { sdk } = useDune();
const opts = config.options ?? {};
const maxNodes = opts.maxNodes ?? 1000;
const initialConnectionLimit = opts.initialConnectionLimit ?? 100;
const features: LiteFeatureFlags = { ...DEFAULT_LITE_FEATURES, ...opts.features };
const whitelistedRelationProps = useMemo(
() =>
opts.whitelistedRelationProps
? new Set(opts.whitelistedRelationProps)
: undefined,
[JSON.stringify(opts.whitelistedRelationProps)]
);
const visualConfig = useMemo(
() => mergeVisualConfig(opts.visualConfig),
[opts.visualConfig]
);
const themeConfig = useMemo(
() => buildReagraphTheme(opts.themeConfig),
[opts.themeConfig]
);
const [layout, setLayout] = useState<LayoutType>(opts.layout ?? "forceDirected2d");
const [selections, setSelections] = useState<string[]>([]);
const [selectedNodeType, setSelectedNodeType] = useState<string | null>(null);
const {
selectedNode,
selectedEdge,
selectNode,
selectEdge,
clearSelection,
} = useGraphSelection();
const {
nodes: bufferNodes,
edges: bufferEdges,
addNodes,
addEdges,
touchNode,
clear: clearBuffer,
} = useNodeBuffer(maxNodes);
const {
dataModel,
isLoading: isDataModelLoading,
error: dataModelError,
} = useDataModelLoader(config.dataModel);
const {
isLoading: isSeedLoading,
error: seedError,
loadInstance,
} = useSeedNode({
dataModel,
initialInstance: config.instance,
addNodes,
addEdges,
clearBuffer,
whitelistedRelationProps,
coreReverseQueries: opts.coreReverseQueries,
viewPriorityConfig: opts.viewPriorityConfig,
initialConnectionLimit,
});
const graphRef = useRef<GraphCanvasRef>(null);
const {
graphData,
displayedGraphData,
reagraphNodes,
reagraphEdges,
displayedStats,
} = useGraphDataPipeline({
bufferNodes,
bufferConnections: bufferEdges,
visualConfig,
});
const [isExpanding, setIsExpanding] = useState(false);
const expandNode = useCallback(
async (nodeId: string) => {
if (!sdk || !dataModel) return;
try {
setIsExpanding(true);
const { space, externalId } = parseInstanceId(nodeId);
const existingIds = new Set(
bufferNodes.map((n) => createInstanceId(n.space, n.externalId))
);
const result = await fetchConnectedNodes(
sdk,
space,
externalId,
existingIds,
dataModel,
initialConnectionLimit,
whitelistedRelationProps,
opts.coreReverseQueries,
opts.viewPriorityConfig
);
addNodes(result.newNodes);
addEdges(result.newEdges);
touchNode(nodeId);
} finally {
setIsExpanding(false);
}
},
[
sdk,
dataModel,
bufferNodes,
addNodes,
addEdges,
touchNode,
initialConnectionLimit,
whitelistedRelationProps,
opts.coreReverseQueries,
opts.viewPriorityConfig,
]
);
const handleNodeClick = useCallback(
(node: GraphNode) => {
selectNode(node);
setSelections([node.id]);
setSelectedNodeType(null);
touchNode(node.id);
},
[selectNode, setSelections, touchNode]
);
const handleEdgeClick = useCallback(
(edge: GraphEdge) => {
selectEdge(edge);
setSelections([edge.id]);
setSelectedNodeType(null);
},
[selectEdge, setSelections]
);
const handleCanvasClick = useCallback(() => {
clearSelection();
setSelections([]);
setSelectedNodeType(null);
}, [clearSelection]);
const handleNodeTypeClick = useCallback(
(typeKey: string) => {
if (selectedNodeType === typeKey) {
setSelectedNodeType(null);
setSelections([]);
clearSelection();
} else {
const nodeIds = displayedGraphData.nodes
.filter((n) => {
const key = n.data?.type
? `${n.data.type.space}:${n.data.type.externalId}`
: "unknown";
return key === typeKey;
})
.map((n) => n.id);
setSelectedNodeType(typeKey);
setSelections(nodeIds);
clearSelection();
}
},
[selectedNodeType, displayedGraphData.nodes, clearSelection]
);
const handleClearNodeTypeSelection = useCallback(() => {
setSelectedNodeType(null);
setSelections([]);
}, []);
const GraphCanvasComponent = useMemo(() => {
const Component: React.FC<{ className?: string }> = ({ className }) => (
<GraphViewerCanvas
reagraphNodes={reagraphNodes}
reagraphEdges={reagraphEdges}
displayedGraphData={displayedGraphData}
layout={layout}
theme={themeConfig}
selections={selections}
selectedNode={selectedNode}
selectedEdge={selectedEdge}
features={features}
selectedNodeType={selectedNodeType}
graphRef={graphRef}
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onCanvasClick={handleCanvasClick}
onExpandNode={expandNode}
onNodeTypeClick={handleNodeTypeClick}
onClearNodeTypeSelection={handleClearNodeTypeSelection}
className={className}
/>
);
Component.displayName = "GraphViewerCanvas";
return Component;
}, [
reagraphNodes,
reagraphEdges,
displayedGraphData,
layout,
themeConfig,
selections,
selectedNode,
selectedEdge,
features,
selectedNodeType,
graphRef,
handleNodeClick,
handleEdgeClick,
handleCanvasClick,
expandNode,
handleNodeTypeClick,
handleClearNodeTypeSelection,
]);
const isLoading = isDataModelLoading || isSeedLoading || isExpanding;
const error = dataModelError || seedError;
return {
GraphCanvas: GraphCanvasComponent,
isLoading,
error,
graphData,
stats: displayedStats,
layout,
setLayout,
selections,
setSelections,
selectedNode,
selectedEdge,
expandNode,
loadInstance,
fitView: () => graphRef.current?.fitNodesInView(),
zoomIn: () => graphRef.current?.zoomIn(),
zoomOut: () => graphRef.current?.zoomOut(),
clear: clearBuffer,
graphRef,
};
}
@@ -0,0 +1,132 @@
import { useEffect, useMemo, useState } from "react";
import { type CDFEdge, type CDFNode, createInstanceId } from "./types";
type BufferedNode = {
node: CDFNode;
lastAccessed: number;
};
type BufferState = {
nodes: Map<string, BufferedNode>;
connections: CDFEdge[];
};
function pruneConnections(connections: CDFEdge[], nodes: Map<string, BufferedNode>) {
const validNodeIds = new Set(Array.from(nodes.keys()));
return connections.filter((connection) => {
const startId = createInstanceId(connection.startNode.space, connection.startNode.externalId);
const endId = createInstanceId(connection.endNode.space, connection.endNode.externalId);
return validNodeIds.has(startId) && validNodeIds.has(endId);
});
}
function evictIfNeeded(state: BufferState, maxSize: number): BufferState {
if (state.nodes.size <= maxSize) {
return {
nodes: state.nodes,
connections: pruneConnections(state.connections, state.nodes),
};
}
const entries = Array.from(state.nodes.entries());
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed);
const toRemove = entries.length - maxSize;
for (let i = 0; i < toRemove; i++) {
state.nodes.delete(entries[i][0]);
}
return {
nodes: state.nodes,
connections: pruneConnections(state.connections, state.nodes),
};
}
export function useNodeBuffer(initialMaxSize = 1000) {
const [maxSize, setMaxSize] = useState(initialMaxSize);
const [state, setState] = useState<BufferState>({
nodes: new Map<string, BufferedNode>(),
connections: [],
});
useEffect(() => {
setState((prev) => evictIfNeeded(prev, maxSize));
}, [maxSize]);
const addNodes = (nodes: CDFNode[]) => {
const now = Date.now();
setState((prev) => {
const nextNodes = new Map(prev.nodes);
nodes.forEach((node) => {
const key = createInstanceId(node.space, node.externalId);
nextNodes.set(key, { node, lastAccessed: now });
});
return evictIfNeeded({ nodes: nextNodes, connections: prev.connections }, maxSize);
});
};
const addEdges = (connections: CDFEdge[]) => {
setState((prev) => {
const existingIds = new Set(
prev.connections.map((c) => createInstanceId(c.space, c.externalId))
);
const merged = [...prev.connections];
connections.forEach((connection) => {
const id = createInstanceId(connection.space, connection.externalId);
if (!existingIds.has(id)) {
merged.push(connection);
}
});
return {
nodes: prev.nodes,
connections: pruneConnections(merged, prev.nodes),
};
});
};
const touchNode = (nodeId: string) => {
setState((prev) => {
const nextNodes = new Map(prev.nodes);
const buffered = nextNodes.get(nodeId);
if (buffered) {
nextNodes.set(nodeId, { ...buffered, lastAccessed: Date.now() });
}
return { nodes: nextNodes, connections: prev.connections };
});
};
const clear = () => {
setState({
nodes: new Map(),
connections: [],
});
};
const setBuffer = (nodes: CDFNode[], connections: CDFEdge[]) => {
const now = Date.now();
const nodesMap = new Map<string, BufferedNode>();
nodes.forEach((node) => {
const key = createInstanceId(node.space, node.externalId);
nodesMap.set(key, { node, lastAccessed: now });
});
const pruned = pruneConnections(connections, nodesMap);
setState(evictIfNeeded({ nodes: nodesMap, connections: pruned }, maxSize));
};
const bufferedNodes = useMemo(
() => Array.from(state.nodes.values()).map((entry) => entry.node),
[state.nodes]
);
return {
nodes: bufferedNodes,
edges: state.connections,
addNodes,
addEdges,
touchNode,
clear,
setBuffer,
maxSize,
setMaxSize,
};
}
@@ -0,0 +1,113 @@
import { useDune } from "@cognite/dune";
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchConnectedNodes, fetchNodeDetails } from "./graph-service";
import type {
CDFEdge,
CDFNode,
DataModelInfo,
ReverseRelationQuery,
ViewPriorityConfig,
} from "./types";
import { createInstanceId } from "./types";
interface UseSeedNodeConfig {
dataModel: DataModelInfo | null;
initialInstance?: { space: string; externalId: string };
addNodes: (nodes: CDFNode[]) => void;
addEdges: (edges: CDFEdge[]) => void;
clearBuffer: () => void;
whitelistedRelationProps?: Set<string>;
coreReverseQueries?: ReverseRelationQuery[];
viewPriorityConfig?: ViewPriorityConfig;
initialConnectionLimit: number;
}
interface UseSeedNodeReturn {
isLoading: boolean;
error: string | null;
loadInstance: (space: string, externalId: string) => Promise<void>;
}
export function useSeedNode({
dataModel,
initialInstance,
addNodes,
addEdges,
clearBuffer,
whitelistedRelationProps,
coreReverseQueries,
viewPriorityConfig,
initialConnectionLimit,
}: UseSeedNodeConfig): UseSeedNodeReturn {
const { sdk } = useDune();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadedRef = useRef(false);
const loadInstance = useCallback(
async (space: string, externalId: string) => {
if (!sdk || !dataModel) {
setError("SDK or data model not ready");
return;
}
try {
setIsLoading(true);
setError(null);
clearBuffer();
const node = await fetchNodeDetails(sdk, space, externalId);
if (!node) {
throw new Error(`Node not found: ${space}/${externalId}`);
}
addNodes([node]);
const seedId = createInstanceId(space, externalId);
const existingIds = new Set([seedId]);
const result = await fetchConnectedNodes(
sdk,
space,
externalId,
existingIds,
dataModel,
initialConnectionLimit,
whitelistedRelationProps,
coreReverseQueries,
viewPriorityConfig
);
addNodes(result.newNodes);
addEdges(result.newEdges);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to load instance"
);
} finally {
setIsLoading(false);
}
},
[
sdk,
dataModel,
addNodes,
addEdges,
clearBuffer,
whitelistedRelationProps,
coreReverseQueries,
viewPriorityConfig,
initialConnectionLimit,
]
);
useEffect(() => {
if (loadedRef.current || !dataModel || !initialInstance || !sdk) return;
loadedRef.current = true;
loadInstance(initialInstance.space, initialInstance.externalId);
}, [dataModel, initialInstance, sdk, loadInstance]);
return { isLoading, error, loadInstance };
}
@@ -0,0 +1,143 @@
---
name: integrate-atlas-chat
description: "MUST be used whenever building a chat UI with Atlas agents in a Flows app. Do NOT manually write useAtlasChat integration code — this skill handles installation, component structure, and hook wiring. Triggers: useAtlasChat, atlas chat, streaming chat, agent chat, chat interface, chat component, chat UI. For a full chat app, run skills in order: (1) integrate-atlas-chat, (2) create-client-tool (per tool), (3) setup-python-tools (if Python tools needed)."
allowed-tools: Read, Glob, Grep, Edit, Write, Bash
metadata:
argument-hint: "[agent-external-id]"
---
# Integrate Atlas Agent Chat
Add a streaming Atlas Agent chat UI to this Flows app.
Agent external ID: **$ARGUMENTS**
## Dependencies
The atlas-agent library files (copied in Step 2) require these npm packages:
| Package | Version |
|---|---|
| `@sinclair/typebox` | `^0.33.0` |
| `ajv` | `^8.17.1` |
| `ajv-formats` | `^2.1.1` |
`@cognite/sdk` is assumed to already be present in Flows apps.
---
## Your job
Complete these steps in order. Read each file before modifying it.
---
## Step 1 — Understand the app
Read these files before touching anything:
- `package.json` — detect package manager (`packageManager` field or lock file) and existing deps
- `src/App.tsx` (or equivalent entry component) — understand current structure
---
## Step 2 — Copy the atlas-agent source files
The atlas-agent library lives in the `code/` directory next to this skill file. Read and copy
the following files into `src/atlas-agent/` inside the app:
- `code/types.ts`
- `code/validation.ts`
- `code/client.ts`
- `code/session.ts`
- `code/react.ts`
> The Python-related files (`python.ts`, `pyodide.ts`, `pyodide-react.ts`, `pyodide-runtime.ts`)
> are only needed if the agent uses Python tools. The `setup-python-tools` skill copies those.
---
## Step 3 — Install dependencies
Install the required peer packages (see **Dependencies** above) using the app's package manager:
- pnpm → `pnpm add @sinclair/typebox@^0.33.0 ajv@^8.17.1 ajv-formats@^2.1.1`
- npm → `npm install @sinclair/typebox@^0.33.0 ajv@^8.17.1 ajv-formats@^2.1.1`
- yarn → `yarn add @sinclair/typebox@^0.33.0 ajv@^8.17.1 ajv-formats@^2.1.1`
---
## Step 4 — Build the chat component
Replace (or create) the main `App.tsx` with a full chat UI. The component must:
1. **Import** `useAtlasChat` and `ChatMessage` from `./atlas-agent/react` (relative to the component)
2. **Get the SDK** via `useDune()` from `@cognite/dune`
3. **Pass `null` while loading**`client: isLoading ? null : sdk`
4. **Show streaming text** in real time using `msg.isStreaming` with a blinking cursor
5. **Show tool call events** — when `progress.startsWith("Executing:")`, render it distinctly
(e.g. a ⚙ icon + monospace tool name) so tool calls are clearly visible
6. **Show tool calls** — each assistant `message.toolCalls` (after streaming completes)
should appear as expandable cards beneath the message
7. **Abort button** — show a "Stop" button while `isStreaming`, wired to `abort()`
8. **Reset button** — "New chat" button wired to `reset()`
9. **Auto-scroll** — scroll to bottom on new messages and progress updates
10. **Auto-resize textarea** — expand up to ~120px, submit on Enter, newline on Shift+Enter
### Key hook API
```ts
import { useAtlasChat } from "./atlas-agent/react";
import type { ChatMessage } from "./atlas-agent/react";
const { messages, send, isStreaming, progress, error, reset, abort } = useAtlasChat({
client: isLoading ? null : sdk, // null-safe — hook waits for a real client
agentExternalId: "...",
tools?: AtlasTool[], // optional client-side tools
});
// messages[n].role — "user" | "assistant"
// messages[n].text — full text (streams chunk-by-chunk via isStreaming)
// messages[n].isStreaming — true while this message is being written
// messages[n].toolCalls — ToolCall[] once response is complete (client + server-side, in call order)
// progress — e.g. "Agent thinking" or "Executing: get_timeseries"
// isStreaming — true for the entire duration of a response
```
### Tool call display pattern
```tsx
// During streaming — show as a distinct "tool call" bubble above the message
{isStreaming && progress?.startsWith("Executing:") && (
<div> {progress}</div>
)}
// After response — show tool calls on the assistant message
{msg.toolCalls?.map((tc, i) => (
<ToolResult key={i} name={tc.name} output={tc.output} details={tc.details} />
))}
```
---
## Step 5 — Python tools (optional)
If the agent has Python tools (type `runPythonCode` in its CDF config), run the
`setup-python-tools` skill to add Pyodide-based client-side execution:
```
/setup-python-tools $ARGUMENTS
```
That skill copies the Python-related source files from `@skills/integrate-atlas-chat/code`,
installs `pyodide`, sets up `usePyodideRuntime`, and wires the runtime into
`useAtlasChat` via `pythonRuntime`. The library fetches Python tool code from the agent
config automatically — no `PythonToolConfig` entries needed.
You don't need this if the agent only uses built-in or regular client tools.
---
## Done
Start the app and you should see a streaming chat UI connected to Atlas Agent `$ARGUMENTS`.
@@ -0,0 +1,153 @@
/**
* AtlasClient — stateless HTTP/SSE transport layer.
*
* Single responsibility: send chat payloads to the Cognite AI agent API
* and parse the response (JSON or Server-Sent Events).
*/
import type { CogniteClient } from '@cognite/sdk';
import type { Agent, ChatPayload, RawAgentResponse, StreamCallbacks } from './types';
const CDF_API_VERSION = 'alpha';
const AGENTS_API_VERSION = 'beta';
export class AtlasClient {
private readonly client: CogniteClient;
constructor(client: CogniteClient) {
this.client = client;
}
async listAgents(): Promise<Agent[]> {
const { data } = await this.client.get<{ items: Agent[] }>(
`/api/v1/projects/${this.client.project}/ai/agents`,
{ headers: { 'cdf-version': AGENTS_API_VERSION } },
);
return data.items;
}
async getAgentById(externalId: string): Promise<Agent | null> {
const { data } = await this.client.post<{ items: Agent[] }>(
`/api/v1/projects/${this.client.project}/ai/agents/byids`,
{ data: { items: [{ externalId }] }, headers: { 'cdf-version': AGENTS_API_VERSION } },
);
return data.items[0] ?? null;
}
/**
* Post a chat payload and parse the response (JSON or SSE).
* @param agentExternalId — used as a fallback identifier when the SSE result event omits agent IDs.
*/
async post(
payload: ChatPayload,
agentExternalId: string,
callbacks?: StreamCallbacks,
signal?: AbortSignal,
): Promise<RawAgentResponse> {
const url = `${this.client.getBaseUrl()}/api/v1/projects/${this.client.project}/ai/internal/agents/chat`;
const response = await fetch(url, {
method: 'POST',
headers: {
...this.client.getDefaultRequestHeaders(),
'Content-Type': 'application/json',
'cdf-version': CDF_API_VERSION,
},
body: JSON.stringify(payload),
signal,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
`Agent chat API error: ${response.status} - ${JSON.stringify(errorData)}`,
);
}
const contentType = response.headers.get('content-type') || '';
if (
contentType.includes('text/event-stream') ||
contentType.includes('text/plain')
) {
return this.parseSSE(response, agentExternalId, callbacks);
}
return await response.json();
}
/**
* Parse a Server-Sent Events streaming response.
*/
private async parseSSE(
response: Response,
agentExternalId: string,
callbacks?: StreamCallbacks,
): Promise<RawAgentResponse> {
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Response body is not readable');
}
const decoder = new TextDecoder();
let buffer = '';
let finalResponse: RawAgentResponse | null = null;
const processLine = (line: string) => {
if (!line.startsWith('data: ')) return;
const dataStr = line.slice(6).trim();
if (dataStr === '[DONE]') return '[DONE]' as const;
try {
const data = JSON.parse(dataStr);
const sseResponse = data.response;
if (!sseResponse) return;
if (sseResponse.type === 'progress' && sseResponse.content) {
callbacks?.onProgress?.(sseResponse.content);
} else if (
sseResponse.type === 'responseChunk' &&
sseResponse.content
) {
callbacks?.onChunk?.(sseResponse.content);
} else if (sseResponse.type === 'result') {
finalResponse = {
agentId: data.agentId || agentExternalId,
agentExternalId: data.agentExternalId || agentExternalId,
response: {
type: 'result',
cursor: sseResponse.cursor,
messages: sseResponse.messages || [],
},
};
}
} catch {
// Skip unparseable SSE lines
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
if (lines.some((line) => processLine(line) === '[DONE]')) break;
}
} finally {
reader.releaseLock();
}
if (!finalResponse) {
throw new Error('No result response received from streaming API');
}
return finalResponse;
}
}
@@ -0,0 +1,31 @@
/**
* Atlas Agent Client — public API.
*
* Core library for communicating with Cognite Atlas AI agents.
* Self-contained, zero imports from outside this directory (except external packages).
*
* React hook is a separate import path:
* import { useAtlasChat } from '@cognite/dune-utils/atlas-agent/react';
*/
// Core
export { AtlasSession } from './session';
export { AtlasClient } from './client';
// TypeBox re-exports for convenience
export { Type } from '@sinclair/typebox';
export type { Static, TSchema } from '@sinclair/typebox';
// Types
export type {
Agent,
AgentToolConfig,
AtlasTool,
AtlasToolResult,
AtlasResponse,
AtlasSessionConfig,
ToolCall,
StreamCallbacks,
ApiToolDefinition,
PythonRuntime,
} from './types';
@@ -0,0 +1,146 @@
/**
* usePyodideRuntime — React hook for managing PyodideRuntime lifecycle.
*
* Separate entry point so the core atlas-agent bundle stays Pyodide-free.
*/
import { useState, useRef, useEffect } from 'react';
import type { CogniteClient } from '@cognite/sdk';
import { getGlobalPyodideRuntime } from './pyodide-runtime';
import type { PyodideRuntimeConfig } from './pyodide-runtime';
import type { PythonRuntime } from './types';
// ============================================================================
// Types
// ============================================================================
export interface PyodideProgress {
stage: string;
percent: number;
}
export interface UsePyodideRuntimeOptions {
/** The `loadPyodide` function from the `pyodide` package. */
loadPyodide: PyodideRuntimeConfig['loadPyodide'];
/** CogniteClient for SDK credential injection. `null` disables initialization. */
client: CogniteClient | null;
/** Additional Python packages to install via micropip. */
requirements?: string[];
/** CDN URL for Pyodide files. */
cdnUrl?: string;
}
export interface UsePyodideRuntimeReturn {
/** The initialized runtime, or undefined if not yet ready. */
runtime: PythonRuntime | undefined;
/** True while Pyodide is loading / initializing. */
loading: boolean;
/** Error message if initialization failed. */
error: string | null;
/** Current initialization progress. */
progress: PyodideProgress;
/** Convenience: true when runtime is ready to use. */
isReady: boolean;
}
// ============================================================================
// Hook
// ============================================================================
const DEFAULT_BASE_URL = 'https://api.cognitedata.com';
/**
* Manages PyodideRuntime initialization lifecycle.
*
* Loads Pyodide, installs packages, injects Cognite SDK credentials,
* and returns a ready-to-use `PythonRuntime` with loading/error state.
*
* ```tsx
* import { loadPyodide } from 'pyodide';
*
* const { runtime, loading, progress, isReady } = usePyodideRuntime({
* loadPyodide,
* client: sdk,
* requirements: ['pandas', 'numpy'],
* });
* ```
*/
export function usePyodideRuntime(
options: UsePyodideRuntimeOptions,
): UsePyodideRuntimeReturn {
const { client } = options;
const [runtime, setRuntime] = useState<PythonRuntime>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<PyodideProgress>({ stage: '', percent: 0 });
// Refs for values that shouldn't trigger re-initialization
const loadPyodideRef = useRef(options.loadPyodide);
const requirementsRef = useRef(options.requirements);
const cdnUrlRef = useRef(options.cdnUrl);
loadPyodideRef.current = options.loadPyodide;
requirementsRef.current = options.requirements;
cdnUrlRef.current = options.cdnUrl;
useEffect(() => {
if (!client) {
setLoading(false);
return;
}
let mounted = true;
(async () => {
try {
setLoading(true);
setError(null);
const instance = getGlobalPyodideRuntime({
loadPyodide: loadPyodideRef.current,
requirements: requirementsRef.current,
cdnUrl: cdnUrlRef.current,
onProgress: (stage, percent) => {
if (mounted) setProgress({ stage, percent });
},
});
if (!instance.isInitialized) {
if (mounted) setProgress({ stage: 'Initializing...', percent: 0 });
const headers = client.getDefaultRequestHeaders();
const token = headers.Authorization?.split(' ')[1] ?? '';
await instance.initialize({
project: client.project,
baseUrl: client.getBaseUrl?.() ?? DEFAULT_BASE_URL,
token,
});
}
if (mounted) {
setRuntime(instance);
setProgress({ stage: 'Ready', percent: 100 });
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : String(err));
}
} finally {
if (mounted) setLoading(false);
}
})();
return () => {
mounted = false;
};
}, [client]);
return {
runtime,
loading,
error,
progress,
isReady: !loading && !error && runtime !== undefined,
};
}
@@ -0,0 +1,295 @@
/**
* PyodideRuntime browser-based Python execution via Pyodide.
*
* Wraps Pyodide loading, package installation, Cognite SDK setup,
* and localStorage caching into a clean PythonRuntime implementation.
*
* The consumer owns the 'pyodide' npm package they pass `loadPyodide`
* as a config parameter so this module has no hard dependency on it.
*/
import type { PythonRuntime } from './types';
// ============================================================================
// Minimal Pyodide Interfaces (avoids 'pyodide' package dependency)
// ============================================================================
interface PyodideGlobals {
get(name: string): unknown;
set(name: string, value: unknown): void;
}
/** Subset of PyodideInterface that this module uses. */
export interface PyodideInstance {
loadPackage(packages: string[]): Promise<void>;
runPython(code: string): unknown;
runPythonAsync(code: string): Promise<unknown>;
globals: PyodideGlobals;
pyimport(name: string): unknown;
}
interface Micropip {
install(packages: string | string[]): Promise<void>;
}
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_CDN_URL = 'https://cdn.jsdelivr.net/pyodide/v0.29.3/full/';
const CACHE_KEY = 'dune_pyodide_initialized';
const CACHE_VERSION = 'v1';
// ============================================================================
// Config Types
// ============================================================================
export interface PyodideRuntimeConfig {
/** The `loadPyodide` function from the `pyodide` package. */
loadPyodide: (options: { indexURL: string }) => Promise<PyodideInstance>;
/** CDN URL for Pyodide files. Defaults to jsDelivr v0.29.3. */
cdnUrl?: string;
/** Additional Python packages to install via micropip. */
requirements?: string[];
/** Progress callback for initialization stages. */
onProgress?: (stage: string, percent: number) => void;
}
export interface PyodideSDKConfig {
project: string;
baseUrl: string;
token: string;
}
// ============================================================================
// Python Utility Code (injected at init)
// ============================================================================
const PYTHON_UTILS = `
import json
def _serialize_cognite_object(obj, depth=0):
if depth > 10: return str(obj)
for attr in ('dump', 'as_dict'):
fn = getattr(obj, attr, None)
if fn:
try: return fn()
except: pass
if isinstance(obj, dict):
return {k: _serialize_cognite_object(v, depth+1) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_serialize_cognite_object(i, depth+1) for i in obj]
if isinstance(obj, (str, int, float, bool, type(None))):
return obj
d = getattr(obj, '__dict__', None)
if d is not None:
try: return _serialize_cognite_object(d, depth+1)
except: pass
return str(obj)
def as_json_string(value):
return json.dumps(_serialize_cognite_object(value))
`;
// ============================================================================
// Cache Helpers
// ============================================================================
function isCacheValid(): boolean {
try {
return localStorage.getItem(CACHE_KEY) === CACHE_VERSION;
} catch {
return false;
}
}
function markCacheValid(): void {
try {
localStorage.setItem(CACHE_KEY, CACHE_VERSION);
} catch {
/* localStorage unavailable */
}
}
/** Clear the Pyodide package cache — forces re-download on next init. */
export function clearPyodideCache(): void {
try {
localStorage.removeItem(CACHE_KEY);
} catch {
/* localStorage unavailable */
}
}
// ============================================================================
// PyProxy Detection (structural — avoids importing from 'pyodide')
// ============================================================================
function isPyProxy(value: unknown): value is { destroy(): void } {
return (
value != null &&
typeof value === 'object' &&
'destroy' in value &&
typeof (value as Record<string, unknown>).destroy === 'function'
);
}
function destroyIfPyProxy(value: unknown): void {
if (isPyProxy(value)) {
value.destroy();
}
}
// ============================================================================
// PyodideRuntime Class
// ============================================================================
/**
* PythonRuntime backed by Pyodide. Handles loading, package installation,
* Cognite SDK credential injection, caching, and PyProxy conversion.
*/
export class PyodideRuntime implements PythonRuntime {
private pyodide?: PyodideInstance;
private micropip?: Micropip;
private _initialized = false;
private readonly config: PyodideRuntimeConfig;
constructor(config: PyodideRuntimeConfig) {
this.config = config;
}
get isInitialized(): boolean {
return this._initialized;
}
/**
* Load Pyodide, install packages, and set up the Cognite SDK.
* Safe to call multiple times subsequent calls are no-ops.
*/
async initialize(sdk: PyodideSDKConfig): Promise<void> {
if (this._initialized) return;
const report = this.config.onProgress ?? (() => {});
const cdnUrl = this.config.cdnUrl ?? DEFAULT_CDN_URL;
// 1. Load Pyodide
report('Loading Pyodide...', 10);
this.pyodide = await this.config.loadPyodide({ indexURL: cdnUrl });
report('Pyodide loaded', 30);
// 2. Core packages (micropip + HTTP patching)
report('Loading core packages...', 40);
await this.pyodide.loadPackage(['micropip', 'pyodide-http']);
await this.pyodide.runPythonAsync(`
try:
import pyodide_http
pyodide_http.patch_all()
except Exception:
pass
`);
this.micropip = this.pyodide.pyimport('micropip') as Micropip;
// 3. Cognite SDK
const verb = isCacheValid() ? 'Loading' : 'Downloading';
report(`${verb} cognite-sdk...`, 60);
await this.micropip.install('cognite-sdk');
if (!isCacheValid()) markCacheValid();
report('cognite-sdk ready', 80);
// 4. Additional packages
const reqs = this.config.requirements ?? [];
if (reqs.length > 0) {
report('Installing packages...', 85);
await this.micropip.install(reqs);
}
// 5. Utility functions + Cognite client
report('Setting up environment...', 90);
this.pyodide.runPython(PYTHON_UTILS);
report('Initializing Cognite client...', 95);
this.pyodide.runPython(`
import os
os.environ["COGNITE_PROJECT"] = "${sdk.project}"
os.environ["COGNITE_BASE_URL"] = "${sdk.baseUrl}"
os.environ["COGNITE_TOKEN"] = "${sdk.token}"
os.environ["COGNITE_FUSION_NOTEBOOK"] = "1"
os.environ["MPLBACKEND"] = "AGG"
from cognite.client import CogniteClient
client = CogniteClient()
`);
this._initialized = true;
report('Ready', 100);
}
/** Execute Python code asynchronously. PyProxy results are converted to JSON-safe values. */
async runCodeAsync(code: string): Promise<unknown> {
const pyodide = this.requirePyodide();
const raw = await pyodide.runPythonAsync(code);
return this.toJsonSafe(raw);
}
/** Refresh the Cognite SDK token (e.g. after token rotation). */
refreshToken(token: string): void {
this.requirePyodide().runPython(
`import os; os.environ["COGNITE_TOKEN"] = "${token}"`,
);
}
private requirePyodide(): PyodideInstance {
if (!this.pyodide) {
throw new Error(
'PyodideRuntime not initialized — call initialize() first',
);
}
return this.pyodide;
}
/** Convert a Pyodide result to a JSON-safe JS value. */
private toJsonSafe(value: unknown): unknown {
if (value == null) return undefined;
if (!isPyProxy(value)) return value;
const pyodide = this.requirePyodide();
const converter = pyodide.globals.get('as_json_string') as
| ((obj: unknown) => string)
| undefined;
if (!converter) {
throw new Error(
'as_json_string not available — was initialize() called?',
);
}
try {
return JSON.parse((converter as (obj: unknown) => string)(value));
} finally {
destroyIfPyProxy(converter);
destroyIfPyProxy(value);
}
}
}
// ============================================================================
// Singleton
// ============================================================================
let globalInstance: PyodideRuntime | undefined;
/**
* Get or create the global PyodideRuntime singleton.
* Config is only used on first call subsequent calls return the existing instance.
*/
export function getGlobalPyodideRuntime(
config: PyodideRuntimeConfig,
): PyodideRuntime {
if (!globalInstance) {
globalInstance = new PyodideRuntime(config);
}
return globalInstance;
}
/** Reset the global runtime (e.g. on logout). */
export function resetGlobalPyodideRuntime(): void {
globalInstance = undefined;
}
@@ -0,0 +1,22 @@
/**
* Pyodide entry point browser-based Python execution for Atlas agents.
*
* Import path: @cognite/dune-utils/atlas-agent/pyodide
*/
// Runtime
export {
PyodideRuntime,
getGlobalPyodideRuntime,
resetGlobalPyodideRuntime,
clearPyodideCache,
} from './pyodide-runtime';
export type { PyodideRuntimeConfig, PyodideSDKConfig, PyodideInstance } from './pyodide-runtime';
// React hook
export { usePyodideRuntime } from './pyodide-react';
export type {
PyodideProgress,
UsePyodideRuntimeOptions,
UsePyodideRuntimeReturn,
} from './pyodide-react';
@@ -0,0 +1,45 @@
/**
* Python execution primitives for Pyodide-based tool execution.
*
* buildWrapper combines tool code + args into a single runnable Python snippet
* formatOutput serialises the Pyodide result into a string for the agent
*/
/**
* Unicode-safe base64 encoding.
* Encodes to UTF-8 bytes first so non-ASCII characters survive round-tripping.
*/
function toBase64(str: string): string {
const bytes = new TextEncoder().encode(str);
let binary = '';
for (const b of bytes) {
binary += String.fromCharCode(b);
}
return btoa(binary);
}
/**
* Build a Python wrapper that loads the tool code, parses base64-encoded args,
* and calls handle(**args). Supports both sync and async handle functions.
*/
export function buildWrapper(code: string, argsJson: string): string {
const encoded = toBase64(argsJson);
return `
import json, base64, inspect
_args = json.loads(base64.b64decode("${encoded}").decode("utf-8"))
${code}
async def _exec():
if "handle" not in globals():
return {"_error": "No handle() function found in tool code"}
return await handle(**_args) if inspect.iscoroutinefunction(handle) else handle(**_args)
_r = await _exec()
json.dumps(_r) if _r is not None and not isinstance(_r, str) else _r
`.trimStart();
}
/** Stringify Python result into tool output text. */
export function formatOutput(raw: unknown): string {
if (raw == null) return '';
if (typeof raw === 'string') return raw;
return JSON.stringify(raw);
}
@@ -0,0 +1,278 @@
/**
* useAtlasChat plug-and-play React hook for Atlas agent conversations.
*
* Manages session lifecycle, message state, streaming, and abort support.
* Separate entry point from core for tree-shaking.
*/
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import type { CogniteClient } from '@cognite/sdk';
import { AtlasSession } from './session';
import type { AtlasTool, AtlasResponse, PythonRuntime, ToolCall } from './types';
// ============================================================================
// Types
// ============================================================================
export interface ChatMessage<TContext = unknown> {
id: string;
role: 'user' | 'assistant';
text: string;
timestamp: Date;
isStreaming?: boolean;
/** Tool calls (client-side and server-side) attached to this message */
toolCalls?: ToolCall[];
/** App-specific context data, populated via onResponse */
context?: TContext;
}
export interface UseAtlasChatOptions<TContext = unknown> {
client: CogniteClient | null;
agentExternalId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools?: AtlasTool<any, any>[];
/** Opt-in Python runtime (e.g. Pyodide) — only needed for agents that use Python tools. */
pythonRuntime?: PythonRuntime;
/** Messages to show on initial render (e.g. welcome message) */
initialMessages?: ChatMessage<TContext>[];
/** Called when a full response is received. Return context to merge into the assistant message. */
onResponse?: (response: AtlasResponse) => TContext | void;
/** Called before each send to inject app-level context (e.g. current todo state) into the request. */
getAppContext?: () => string | undefined;
}
export interface UseAtlasChatReturn<TContext = unknown> {
/** All messages in the conversation */
messages: ChatMessage<TContext>[];
/** Send a user message — automatically creates user + assistant messages, handles streaming */
send: (text: string) => Promise<void>;
/** True while the agent is responding */
isStreaming: boolean;
/** Current progress text (e.g. "Agent thinking", "Executing: render_widget") */
progress: string | null;
/** Error message if last send failed */
error: string | null;
/** Clear all messages and reset the session */
reset: () => void;
/** Cancel the current streaming response */
abort: () => void;
/** Replace messages (e.g. loading conversation history) */
setMessages: (messages: ChatMessage<TContext>[]) => void;
}
// ============================================================================
// Helpers
// ============================================================================
let messageCounter = 0;
function generateId(): string {
return `msg-${Date.now()}-${++messageCounter}`;
}
// ============================================================================
// Hook
// ============================================================================
export function useAtlasChat<TContext = unknown>(
options: UseAtlasChatOptions<TContext>,
): UseAtlasChatReturn<TContext> {
const { client, agentExternalId, tools, pythonRuntime, initialMessages, onResponse, getAppContext } = options;
const [messages, setMessages] = useState<ChatMessage<TContext>[]>(initialMessages ?? []);
const [isStreaming, setIsStreaming] = useState(false);
const [progress, setProgress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const sessionRef = useRef<AtlasSession | null>(null);
const abortRef = useRef<AbortController | null>(null);
const agentExternalIdRef = useRef(agentExternalId);
const toolsRef = useRef(tools);
const pythonRuntimeRef = useRef(pythonRuntime);
const getAppContextRef = useRef(getAppContext);
// Keep refs updated (array/object identity may change between renders)
toolsRef.current = tools;
pythonRuntimeRef.current = pythonRuntime;
getAppContextRef.current = getAppContext;
// Stable wrapper — always delegates to the latest getAppContext via ref.
// Passed to AtlasSession once at creation so the session is never stale.
const stableGetAppContext = useMemo(
() => () => getAppContextRef.current?.(),
[],
);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
const getSession = useCallback((): AtlasSession | null => {
if (!client) return null;
if (!sessionRef.current || agentExternalIdRef.current !== agentExternalId) {
sessionRef.current = new AtlasSession({
client,
agentExternalId,
tools: toolsRef.current,
pythonRuntime: pythonRuntimeRef.current,
getAppContext: stableGetAppContext,
});
agentExternalIdRef.current = agentExternalId;
}
return sessionRef.current;
}, [client, agentExternalId]);
const send = useCallback(
async (text: string) => {
const session = getSession();
if (!session || isStreaming) return;
setError(null);
setIsStreaming(true);
setProgress('Agent thinking');
// Add user message
const userMessage: ChatMessage<TContext> = {
id: generateId(),
role: 'user',
text,
timestamp: new Date(),
};
const assistantId = generateId();
let accumulatedText = '';
let assistantCreated = false;
setMessages((prev) => [...prev, userMessage]);
const abortController = new AbortController();
abortRef.current = abortController;
// ---- Helpers scoped to this send() call ----
/** Update a single message by id */
const updateMsg = (id: string, updates: Partial<ChatMessage<TContext>>) => {
setMessages((prev) =>
prev.map((m) => (m.id === id ? { ...m, ...updates } : m)),
);
};
/** Finalize the assistant message — update if already created, otherwise add a new one */
const finalizeAssistant = (fields: Partial<ChatMessage<TContext>>) => {
if (assistantCreated) {
updateMsg(assistantId, { isStreaming: false, ...fields });
} else {
setMessages((prev) => [
...prev,
{
id: assistantId,
role: 'assistant' as const,
timestamp: new Date(),
text: '',
isStreaming: false,
...fields,
},
]);
}
};
try {
const response = await session.send(
text,
{
onProgress: (progressText) => {
setProgress(progressText);
},
onChunk: (chunk) => {
if (!assistantCreated) {
assistantCreated = true;
setMessages((prev) => [
...prev,
{
id: assistantId,
role: 'assistant' as const,
text: chunk,
timestamp: new Date(),
isStreaming: true,
},
]);
}
accumulatedText += chunk;
updateMsg(assistantId, { text: accumulatedText });
},
onToolStart: (toolName) => {
setProgress(`Executing: ${toolName}`);
},
},
abortController.signal,
);
// Finalize assistant message
finalizeAssistant({
text:
response.text ||
(assistantCreated
? undefined
: "I apologize, but I couldn't generate a response. Please try again."),
toolCalls:
response.toolCalls.length > 0
? response.toolCalls
: undefined,
});
// Let the app attach context (e.g. applications) to the message
const ctx = onResponse?.(response);
if (ctx !== undefined) {
updateMsg(assistantId, { context: ctx });
}
} catch (err) {
if ((err as Error).name === 'AbortError') {
// Cancelled by user — finalize any in-progress message
if (assistantCreated) {
updateMsg(assistantId, { isStreaming: false });
}
} else {
const errorText =
err instanceof Error ? err.message : 'Unknown error';
setError(errorText);
finalizeAssistant({ text: `Error: ${errorText}` });
}
} finally {
setIsStreaming(false);
setProgress(null);
abortRef.current = null;
}
},
[getSession, isStreaming, onResponse],
);
const reset = useCallback(() => {
abortRef.current?.abort();
setMessages(initialMessages ?? []);
setIsStreaming(false);
setProgress(null);
setError(null);
sessionRef.current = null;
}, [initialMessages]);
const abort = useCallback(() => {
abortRef.current?.abort();
}, []);
return {
messages,
send,
isStreaming,
progress,
error,
reset,
abort,
setMessages,
};
}
@@ -0,0 +1,155 @@
import { describe, it, expect, vi } from 'vitest';
import { AtlasSession } from './session';
import type { AtlasSessionConfig, RawAgentResponse } from './types';
/**
* Minimal mock that satisfies CogniteClient just enough for AtlasSession.
* AtlasClient.post is the only call path we exercise, so we stub it via the
* prototype after construction.
*/
function createMockConfig(
overrides?: Partial<AtlasSessionConfig>,
): AtlasSessionConfig {
return {
client: {} as AtlasSessionConfig['client'],
agentExternalId: 'test-agent',
...overrides,
};
}
/** Build a raw response with a tool action that requests a client tool call. */
function responseWithToolAction(
actionId: string,
toolName: string,
args: Record<string, unknown>,
): RawAgentResponse {
return {
agentId: 'test-agent',
agentExternalId: 'test-agent',
response: {
type: 'result',
cursor: 'cursor-1',
messages: [
{
role: 'assistant',
actions: [
{
type: 'clientTool',
actionId,
clientTool: { name: toolName, arguments: args },
},
],
},
],
},
};
}
/** Build a terminal response (no actions). */
function terminalResponse(text: string): RawAgentResponse {
return {
agentId: 'test-agent',
agentExternalId: 'test-agent',
response: {
type: 'result',
cursor: 'cursor-2',
messages: [{ role: 'assistant', content: { type: 'text', text } }],
},
};
}
describe(AtlasSession.name, () => {
let postSpy: ReturnType<typeof vi.fn>;
function createSession(config?: Partial<AtlasSessionConfig>): AtlasSession {
const session = new AtlasSession(createMockConfig(config));
// Stub the internal client.post so we never hit the network.
postSpy = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(session as any).client = { post: postSpy };
return session;
}
describe('appContext in continuation turns', () => {
it('includes contextInformation on the initial user message', async () => {
const session = createSession({
getAppContext: () => 'todo state here',
});
postSpy.mockResolvedValueOnce(terminalResponse('Done'));
await session.send('hello');
const payload = postSpy.mock.calls[0][0];
expect(payload.contextInformation).toEqual({
appContext: 'todo state here',
});
});
it('includes contextInformation on continuation turns after tool execution', async () => {
let callCount = 0;
const session = createSession({
getAppContext: () => {
callCount++;
return `context-v${callCount}`;
},
tools: [
{
name: 'TestTool',
description: 'test',
parameters: { type: 'object', properties: {} },
execute: () => ({ output: 'ok' }),
},
],
});
// Turn 1: agent requests a tool call
postSpy.mockResolvedValueOnce(
responseWithToolAction('action-1', 'TestTool', {}),
);
// Turn 2: terminal response
postSpy.mockResolvedValueOnce(terminalResponse('All done'));
await session.send('do something');
// First call: initial payload
expect(postSpy).toHaveBeenCalledTimes(2);
const initialPayload = postSpy.mock.calls[0][0];
expect(initialPayload.contextInformation).toEqual({
appContext: 'context-v1',
});
// Second call: continuation payload after tool execution
const continuationPayload = postSpy.mock.calls[1][0];
expect(continuationPayload.contextInformation).toEqual({
appContext: 'context-v2',
});
});
it('omits contextInformation when getAppContext returns undefined', async () => {
const session = createSession({
getAppContext: () => undefined,
});
postSpy.mockResolvedValueOnce(terminalResponse('Done'));
await session.send('hello');
const payload = postSpy.mock.calls[0][0];
expect(payload.contextInformation).toBeUndefined();
});
it('omits contextInformation when getAppContext is not provided', async () => {
const session = createSession();
postSpy.mockResolvedValueOnce(terminalResponse('Done'));
await session.send('hello');
const payload = postSpy.mock.calls[0][0];
expect(payload.contextInformation).toBeUndefined();
});
});
});
@@ -0,0 +1,325 @@
/**
* AtlasSession stateful conversation with validated tool execution.
*
* Manages the cursor, converts AtlasTool[] to API actions format,
* validates tool arguments with ajv, and runs the tool execution loop.
*/
import { AtlasClient } from './client';
import { buildWrapper, formatOutput } from './python';
import { validateToolArguments } from './validation';
import type {
AtlasTool,
AtlasToolResult,
AtlasResponse,
AtlasSessionConfig,
AgentToolConfig,
PythonRuntime,
StreamCallbacks,
ChatPayload,
ApiToolDefinition,
RawAction,
RawClientToolAction,
RawAgentResponse,
RequestMessage,
ClientToolActionMessage,
ToolCall,
} from './types';
const MAX_TURNS = 50;
/**
* Parse raw arguments from the API (string or object) into a plain object.
*/
function parseArguments(
raw: string | Record<string, unknown>,
): Record<string, unknown> {
if (typeof raw === 'string') {
try {
return JSON.parse(raw);
} catch {
return {};
}
}
return raw || {};
}
/**
* Extract all actions from all messages in a raw response.
*/
function extractActions(raw: RawAgentResponse): RawAction[] {
return raw.response.messages.flatMap((msg) => msg.actions ?? []);
}
/**
* Extract server-side tool calls from reasoning blocks in a raw response.
* Shape: messages[n].reasoning[n].data[n].toolCall
*/
function extractServerToolCalls(raw: RawAgentResponse): ToolCall[] {
const calls: ToolCall[] = [];
for (const msg of raw.response.messages) {
for (const entry of msg.reasoning ?? []) {
const data = (entry as { data?: unknown[] }).data;
if (!Array.isArray(data)) continue;
for (const item of data) {
const tc = (item as { toolCall?: Record<string, unknown> }).toolCall;
if (!tc) continue;
const result = tc.result as Record<string, unknown> | undefined;
calls.push({
name: String(tc.name ?? ''),
toolType: tc.toolType !== undefined ? String(tc.toolType) : undefined,
input: tc.input,
output: result?.output !== undefined ? String(result.output) : undefined,
details: result?.result,
});
}
}
}
return calls;
}
/**
* Execute a server-declared Python tool via the pythonRuntime.
*/
async function executePythonTool(
action: RawClientToolAction,
toolConfig: AgentToolConfig,
pythonRuntime: PythonRuntime | undefined,
callbacks?: StreamCallbacks,
): Promise<{ result: AtlasToolResult; followup: ClientToolActionMessage }> {
const toolName = action.clientTool.name;
const pythonCode = String(toolConfig.configuration?.pythonCode ?? '').trim();
const fail = (msg: string) => {
const result: AtlasToolResult = { output: msg };
callbacks?.onToolEnd?.(toolName, result);
return { result, followup: createActionReply(action.actionId, result.output) };
};
if (!pythonCode) {
return fail(`ERROR: pythonCode is empty in tool configuration for "${toolName}"`);
}
if (!pythonRuntime) {
return fail(`ERROR: pythonRuntime is required to execute Python tool "${toolName}" but was not provided`);
}
try {
const argsJson = JSON.stringify(parseArguments(action.clientTool.arguments));
const wrapper = buildWrapper(pythonCode, argsJson);
const raw = await pythonRuntime.runCodeAsync(wrapper);
const result: AtlasToolResult = { output: formatOutput(raw) };
callbacks?.onToolEnd?.(toolName, result);
return { result, followup: createActionReply(action.actionId, result.output) };
} catch (err) {
return fail(`ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Execute a client tool action: validate args, run the tool, return result + followup.
*
* Dispatch order:
* 1. Pre-registered JS tool in the tools Map (client-declared tools)
* 2. Server-defined Python tool fetch config from agent API, run via pythonRuntime
*/
async function executeClientTool(
action: RawClientToolAction,
tools: Map<string, AtlasTool>,
fetchToolConfig: (name: string) => Promise<AgentToolConfig | null>,
pythonRuntime: PythonRuntime | undefined,
callbacks?: StreamCallbacks,
): Promise<{ result: AtlasToolResult; followup: ClientToolActionMessage }> {
const toolName = action.clientTool.name;
callbacks?.onToolStart?.(toolName);
// 1. Pre-registered JS tool
const tool = tools.get(toolName);
if (tool) {
const args = parseArguments(action.clientTool.arguments);
try {
validateToolArguments(toolName, tool.parameters, args);
} catch (err) {
const errorOutput = err instanceof Error ? err.message : String(err);
const result: AtlasToolResult = { output: `ERROR: ${errorOutput}` };
callbacks?.onToolEnd?.(toolName, result);
return { result, followup: createActionReply(action.actionId, result.output) };
}
const result = await tool.execute(args);
callbacks?.onToolEnd?.(toolName, result);
return { result, followup: createActionReply(action.actionId, result.output) };
}
// 2. Server-declared tool — look up config from agent API
const toolConfig = await fetchToolConfig(toolName);
if (toolConfig?.type === 'runPythonCode') {
return executePythonTool(action, toolConfig, pythonRuntime, callbacks);
}
const result: AtlasToolResult = { output: `Unknown client tool: ${toolName}` };
callbacks?.onToolEnd?.(toolName, result);
return { result, followup: createActionReply(action.actionId, result.output) };
}
/**
* Stateful conversation session with validated tool execution.
*/
export class AtlasSession {
private cursor?: string;
private readonly client: AtlasClient;
private readonly agentExternalId: string;
private readonly tools: Map<string, AtlasTool>;
private readonly apiActionsOrUndefined: ApiToolDefinition[] | undefined;
private readonly pythonRuntime: PythonRuntime | undefined;
private readonly getAppContext: (() => string | undefined) | undefined;
/** Cached tool configs fetched from the agent API (populated lazily on first Python tool call). */
private cachedAgentTools: AgentToolConfig[] | undefined;
constructor(config: AtlasSessionConfig) {
this.client = new AtlasClient(config.client);
this.agentExternalId = config.agentExternalId;
this.tools = new Map((config.tools || []).map((t) => [t.name, t]));
this.pythonRuntime = config.pythonRuntime;
this.getAppContext = config.getAppContext;
// Inline toApiToolDefinition (only used here)
const apiActions: ApiToolDefinition[] = (config.tools || []).map((tool) => ({
type: 'clientTool' as const,
clientTool: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
},
}));
this.apiActionsOrUndefined = apiActions.length > 0 ? apiActions : undefined;
}
/**
* Send a user message. Handles the full tool execution loop internally.
*/
async send(
message: string,
callbacks?: StreamCallbacks,
signal?: AbortSignal,
): Promise<AtlasResponse> {
const allToolCalls: ToolCall[] = [];
const appContext = this.getAppContext?.();
let payload: ChatPayload = {
agentExternalId: this.agentExternalId,
messages: [{ role: 'user', content: { type: 'text', text: message } }],
actions: this.apiActionsOrUndefined,
stream: true,
...(this.cursor && { cursor: this.cursor }),
...(appContext && { contextInformation: { appContext } }),
};
for (let turn = 0; turn < MAX_TURNS; turn++) {
const raw = await this.client.post(payload, this.agentExternalId, callbacks, signal);
const response = raw.response;
if (response.type !== 'result') {
throw new Error(`Unexpected response type: ${response.type}`);
}
if (response.cursor) {
this.cursor = response.cursor;
}
// Collect server-side tool calls from reasoning blocks in this turn
allToolCalls.push(...extractServerToolCalls(raw));
const actions = extractActions(raw);
// No actions → conversation turn is done
if (actions.length === 0) {
const text = response.messages?.[0]?.content?.text || '';
return { text, cursor: this.cursor, toolCalls: allToolCalls, raw };
}
// Execute actions and build follow-up messages
const followups: RequestMessage[] = [];
for (const action of actions) {
if (action.type === 'clientTool') {
const { result, followup } = await executeClientTool(
action,
this.tools,
(name) => this.fetchToolConfig(name),
this.pythonRuntime,
callbacks,
);
allToolCalls.push({ name: action.clientTool.name, output: result.output, details: result.details });
followups.push(followup);
} else if (action.type === 'toolConfirmation') {
const toolName = action.toolConfirmation?.toolName;
if (toolName) callbacks?.onToolStart?.(toolName);
followups.push({
role: 'action',
type: 'toolConfirmation',
actionId: action.actionId,
status: 'ALLOW',
});
}
}
if (followups.length === 0) {
const text = response.messages?.[0]?.content?.text || '';
return { text, cursor: this.cursor, toolCalls: allToolCalls, raw };
}
// Prepare the next turn — re-evaluate appContext so it reflects state changes from tool execution
const updatedAppContext = this.getAppContext?.();
payload = {
agentExternalId: this.agentExternalId,
messages: followups,
actions: this.apiActionsOrUndefined,
stream: true,
cursor: this.cursor,
...(updatedAppContext && { contextInformation: { appContext: updatedAppContext } }),
};
}
throw new Error(`Max tool execution turns reached (${MAX_TURNS})`);
}
/**
* Fetch a tool's config from the agent API (lazy, cached per session).
* Used as a fallback when a clientTool action arrives for an unregistered tool.
*/
private async fetchToolConfig(toolName: string): Promise<AgentToolConfig | null> {
if (!this.cachedAgentTools) {
const agent = await this.client.getAgentById(this.agentExternalId);
this.cachedAgentTools = agent?.tools ?? [];
}
return this.cachedAgentTools.find((t) => t.name === toolName) ?? null;
}
/** Reset the session cursor (start a fresh conversation). */
reset(): void {
this.cursor = undefined;
}
/** Get the current cursor value. */
getCursor(): string | undefined {
return this.cursor;
}
/** Set the cursor (e.g. when restoring a conversation). */
setCursor(cursor: string): void {
this.cursor = cursor;
}
}
function createActionReply(
actionId: string,
text: string,
): ClientToolActionMessage {
return {
role: 'action',
type: 'clientTool',
actionId,
content: { type: 'text', text },
data: [],
};
}
@@ -0,0 +1,320 @@
/**
* Core types for the Atlas Agent client library.
*
* This module is self-contained no imports from outside the library
* except external packages (@sinclair/typebox, @cognite/sdk).
*/
import type { TSchema, Static } from '@sinclair/typebox';
import type { CogniteClient } from '@cognite/sdk';
// ============================================================================
// Agent Types
// ============================================================================
/** Configuration for a tool stored in the agent's CDF config. */
export interface AgentToolConfig {
name: string;
type: string;
configuration?: {
pythonCode?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface Agent {
externalId: string;
name: string;
description?: string;
model?: string;
instructions?: string;
ownerId?: string;
tools?: AgentToolConfig[];
createdTime?: number;
lastUpdatedTime?: number;
}
// ============================================================================
// Tool Types
// ============================================================================
/** Result from executing a tool */
export interface AtlasToolResult<TDetails = unknown> {
/** Text sent back to the agent as tool output */
output: string;
/** Structured data for the app/UI */
details?: TDetails;
}
/**
* A client-side tool the Atlas agent can invoke.
* TypeBox schema for type-safe params + runtime validation via ajv.
*/
export interface AtlasTool<
TParameters extends TSchema = TSchema,
TDetails = unknown,
> {
name: string;
description: string;
parameters: TParameters;
execute: (
args: Static<TParameters>,
) => AtlasToolResult<TDetails> | Promise<AtlasToolResult<TDetails>>;
}
/** Minimal interface for executing Python code (e.g. Pyodide). */
export interface PythonRuntime {
runCodeAsync(code: string): Promise<unknown>;
}
// ============================================================================
// Callback Types
// ============================================================================
export interface StreamCallbacks {
onProgress?: (text: string) => void;
onChunk?: (text: string) => void;
onToolStart?: (toolName: string) => void;
onToolEnd?: (toolName: string, result: AtlasToolResult) => void;
}
// ============================================================================
// Response Types (app-facing)
// ============================================================================
/** A single tool invocation (client-side or server-side), ready for the UI. */
export interface ToolCall {
/** Friendly display name, e.g. "Find files" */
name: string;
/** Server-side tool type, e.g. "queryKnowledgeGraph" */
toolType?: string;
/** Raw input arguments */
input?: unknown;
/** Text returned to the agent as tool output */
output?: string;
/** Structured data for UI rendering */
details?: unknown;
}
export interface AtlasResponse {
text: string;
cursor?: string;
toolCalls: ToolCall[];
raw: RawAgentResponse;
}
// ============================================================================
// Config Types
// ============================================================================
export interface AtlasSessionConfig {
client: CogniteClient;
agentExternalId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools?: AtlasTool<any, any>[];
/** Opt-in Python runtime (e.g. Pyodide) — required only for agents that use Python tools. */
pythonRuntime?: PythonRuntime;
/** Called before each send to inject app-level context (e.g. current todo state) into the request. */
getAppContext?: () => string | undefined;
}
// ============================================================================
// API Shared Primitives (maps to cog_ai…session.common)
// ============================================================================
/** Maps to AgentContentDTO */
export interface AgentContent {
type: string;
text?: string;
}
/** Maps to InstanceIdDTO */
export interface InstanceId {
space: string;
externalId: string;
}
/** Maps to ViewDTO */
export interface View {
space: string;
externalId: string;
version: string;
}
/** Maps to PropertyVal type alias */
export type PropertyVal =
| string
| number
| boolean
| null
| Record<string, unknown>
| (string | null)[]
| (number | null)[]
| (boolean | null)[]
| (Record<string, unknown> | null)[];
// ============================================================================
// Agent Data Types (maps to AgentDataDTO)
// ============================================================================
/** Maps to InstanceDataDTO — the only variant we narrow on in app code. */
export interface InstanceData {
type: 'instance';
view: View;
instanceId: InstanceId;
properties?: Record<string, PropertyVal>;
}
/**
* Data items attached to response messages.
* Only `InstanceData` is typed other variants pass through as plain objects.
*/
export type AgentData = InstanceData | (Record<string, unknown> & { type: string });
// ============================================================================
// Tool Definition Types (maps to CustomClientActionDTO)
// ============================================================================
/** Maps to clientToolParameters */
export interface ClientToolParameters {
type: 'object';
description?: string;
properties?: Record<string, Record<string, unknown>>;
required?: string[];
propertyOrdering?: string[];
}
/** Maps to CustomClientActionDTO */
export interface ApiToolDefinition {
type: 'clientTool';
clientTool: {
name: string;
description?: string;
parameters: ClientToolParameters;
};
}
// ============================================================================
// Request Message Types (maps to RequestMessageDTO)
// ============================================================================
/** Maps to AgentChatMessageUserRequestDTO */
export interface UserRequestMessage {
role: 'user';
content: AgentContent;
}
/** Maps to ClientToolCallActionMessageDTO */
export interface ClientToolActionMessage {
role: 'action';
type: 'clientTool';
actionId: string;
content: AgentContent;
data: Array<Record<string, unknown>>;
}
/** Maps to UserConfirmationResponseDTO */
export interface UserConfirmationMessage {
role: 'action';
type: 'toolConfirmation';
actionId: string;
status: 'ALLOW' | 'DENY';
}
/** Maps to UserSessionResponseDTO */
export interface UserSessionMessage {
role: 'action';
type: 'userSession';
actionId: string;
nonce: string;
}
/** Maps to RequestMessageDTO (discriminated union) */
export type RequestMessage =
| UserRequestMessage
| ClientToolActionMessage
| UserConfirmationMessage
| UserSessionMessage;
// ============================================================================
// Session Context (maps to AgentSessionContextDTO)
// ============================================================================
export interface AgentSessionContext {
instanceSpaces?: string[];
dataModels?: Array<Record<string, unknown>>;
timeZone?: string;
appContext?: string;
}
// ============================================================================
// Chat Payload (maps to AgentSessionRequest)
// ============================================================================
export interface ChatPayload {
agentExternalId?: string;
messages: RequestMessage[];
actions?: ApiToolDefinition[];
contextInformation?: AgentSessionContext;
cursor?: string;
stream: boolean;
}
// ============================================================================
// Raw Response Types
// ============================================================================
/** Response action: agent requests client to execute a tool */
export interface RawClientToolAction {
type: 'clientTool';
actionId: string;
clientTool: {
name: string;
arguments: string | Record<string, unknown>;
};
}
/** Response action: agent requests user confirmation */
export interface RawToolConfirmationAction {
type: 'toolConfirmation';
actionId: string;
toolConfirmation?: {
toolName?: string;
toolType?: string;
toolArguments?: Record<string, unknown>;
toolDescription?: string;
content?: AgentContent;
};
}
/** Response action: agent requests user session */
export interface RawUserSessionAction {
type: 'userSession';
actionId: string;
}
/** Action from agent response (discriminated by `type`). Unknown types are skipped by the session loop. */
export type RawAction =
| RawClientToolAction
| RawToolConfirmationAction
| RawUserSessionAction;
/** A message in an agent response */
export interface RawMessage {
content?: AgentContent;
role: string;
data?: AgentData[];
reasoning?: Array<Record<string, unknown>>;
actions?: RawAction[];
}
export interface RawAgentResponse {
agentId: string;
agentExternalId: string;
response: {
type: string;
cursor?: string;
messages: RawMessage[];
};
}
@@ -0,0 +1,49 @@
/**
* Runtime validation for tool arguments using ajv.
*
* Ported from pi-mono packages/ai/src/utils/validation.ts pattern:
* - Singleton ajv instance with coercion
* - Graceful degradation if ajv fails to initialise (CSP)
*/
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import type { TSchema } from '@sinclair/typebox';
let ajvInstance: Ajv | null = null;
function getAjv(): Ajv | null {
if (ajvInstance) return ajvInstance;
try {
ajvInstance = new Ajv({ allErrors: true, strict: false, coerceTypes: true });
addFormats(ajvInstance);
return ajvInstance;
} catch {
// Graceful degradation — skip validation if ajv cannot initialise
return null;
}
}
/**
* Validate and coerce tool arguments against a TypeBox / JSON Schema.
* Throws a formatted error on validation failure.
* Gracefully skips validation when ajv is unavailable (e.g. CSP).
*/
export function validateToolArguments(
toolName: string,
schema: TSchema,
args: unknown,
): void {
const ajv = getAjv();
if (!ajv) return;
const validate = ajv.compile(schema);
const valid = validate(args);
if (valid) return;
const errors = validate.errors
?.map((e) => `${e.instancePath || '/'} ${e.message}`)
.join('; ');
throw new Error(`Tool "${toolName}" received invalid arguments: ${errors}`);
}
@@ -0,0 +1,287 @@
---
name: integrate-file-viewer
description: "MUST be used whenever integrating CogniteFileViewer into a Flows app to preview CDF files (PDFs, images, text). Do NOT manually wire up react-pdf or file resolution — this skill handles installation, Vite config, worker setup, and component usage. Triggers: file viewer, file preview, CogniteFileViewer, PDF viewer, view CDF files, document viewer, preview file."
allowed-tools: Read, Glob, Grep, Edit, Write, Bash
---
# Integrate CogniteFileViewer
Add `CogniteFileViewer` to this Flows app to preview CDF files (PDF, image, text).
## Dependencies
The file-viewer library files (copied in Step 2) require this npm package:
| Package | Version |
|---|---|
| `react-pdf` | `^9.1.1` |
`pdfjs-dist` ships as a dependency of `react-pdf` at the correct version — do not install it separately.
`react` and `@cognite/sdk` are assumed to already be present in Flows apps.
---
## Your job
Complete these steps in order. Read each file before modifying it.
---
## Step 1 — Understand the app
Read these files before touching anything:
- `package.json` — detect package manager (`packageManager` field or lock file) and existing deps
- `vite.config.ts` — understand current Vite setup
- The component where the viewer should be added
---
## Step 2 — Copy the file-viewer source files
The file-viewer library lives in the `code/` directory next to this skill file. Read and copy
**all** files from there into `src/cognite-file-viewer/` inside the app:
- `code/types.ts`
- `code/mimeTypes.ts`
- `code/fileResolution.ts`
- `code/useViewport.ts`
- `code/useFileResolver.ts`
- `code/useDocumentAnnotations.ts`
- `code/DocumentAnnotationOverlay.tsx`
- `code/CogniteFileViewer.tsx`
- `code/index.ts`
> The PDF.js worker is configured inside `CogniteFileViewer.tsx` — no separate consumer setup is needed.
---
## Step 3 — Install dependencies
Install `react-pdf` (see **Dependencies** above) using the app's package manager:
- pnpm → `pnpm add react-pdf@^9.1.1`
- npm → `npm install react-pdf@^9.1.1`
- yarn → `yarn add react-pdf@^9.1.1`
> **pnpm users:** pnpm's strict linking may prevent the browser from resolving `pdfjs-dist`. Either add `pdfjs-dist` as a direct dependency (`pnpm add pdfjs-dist`), or add `public-hoist-pattern[]=pdfjs-dist` to `.npmrc`.
---
## Step 4 — Configure Vite
Add `optimizeDeps.exclude: ['pdfjs-dist']` to `vite.config.ts` to prevent Vite from pre-bundling pdfjs-dist (which breaks the worker):
```ts
export default defineConfig({
// ... existing config ...
optimizeDeps: {
exclude: ['pdfjs-dist'],
},
});
```
---
## Step 5 — Use the component
Import and render `CogniteFileViewer` from the locally copied files:
```tsx
import { CogniteFileViewer } from './cognite-file-viewer';
```
Get the `sdk` from the `useDune()` hook (already available in every Flows app):
```tsx
import { useDune } from '@cognite/dune';
const { sdk } = useDune();
```
### Supported file types
| Type | Formats |
|---|---|
| PDF | `.pdf` — page navigation, zoom, pan, diagram annotation overlay |
| Office documents | Word, PowerPoint, Excel, ODS, ODP, ODT, RTF, TSV — converted to PDF via the CDF Document Preview API, then rendered identically to PDF |
| Image | JPEG, PNG, WebP, SVG, TIFF — zoom, pan, rotation |
| Text | `.txt`, `.csv`, `.json` — rendered as preformatted text |
| Other | Falls back to `renderUnsupported` |
### Minimal usage
This is all you need — zoom, pan, and touch gestures are handled internally:
```tsx
<CogniteFileViewer
source={{ type: 'internalId', id: file.id }}
client={sdk}
style={{ width: '100%', height: '600px' }}
/>
```
> **The component needs a defined height.** If the parent has no explicit height, the viewer will collapse to zero. Always set a `height` via `style`, `className`, or the parent container.
### File source
Pass any of three source types:
```tsx
// By instance ID (data-modelled file — enables annotations)
<CogniteFileViewer
source={{ type: 'instanceId', space: 'my-space', externalId: 'my-file' }}
client={sdk}
/>
// By CDF internal ID
<CogniteFileViewer
source={{ type: 'internalId', id: 12345 }}
client={sdk}
/>
// By direct URL
<CogniteFileViewer
source={{ type: 'url', url: 'https://...', mimeType: 'application/pdf' }}
/>
```
**Prefer `instanceId` when available** — it's the only source type that enables the diagram annotation overlay. When listing files via `sdk.files.list()`, check `file.instanceId` first:
```tsx
source={
file.instanceId
? { type: 'instanceId', space: file.instanceId.space, externalId: file.instanceId.externalId }
: { type: 'internalId', id: file.id }
}
```
### Full props reference
```tsx
<CogniteFileViewer
// Required
source={source}
client={sdk} // required for instanceId and internalId sources
// PDF pagination
page={page} // controlled current page (1-indexed)
onPageChange={setPage}
onDocumentLoad={({ numPages }) => setNumPages(numPages)}
// Zoom & pan (works on PDF and images)
zoom={zoom} // 1 = 100%; Ctrl/Cmd+wheel, pinch-to-zoom, and middle-click drag built in
onZoomChange={setZoom}
minZoom={0.25} // default
maxZoom={5} // default
panOffset={pan} // controlled pan offset; resets on page change
onPanChange={setPan}
// Fit mode
fitMode="width" // 'width' fits to container width; 'page' fits entire page in container
// Rotation (PDFs and images)
rotation={rotation} // 0 | 90 | 180 | 270
// Diagram annotations (instanceId sources only)
showAnnotations={true} // default
onAnnotationClick={(annotation) => { /* annotation.linkedResource has space + externalId */ }}
onAnnotationHover={(annotation) => {}}
// Custom annotation tooltip (replaces native <title> tooltip)
renderAnnotationTooltip={(annotation, rect) => (
<div style={{
position: 'absolute',
left: rect.x + rect.width,
top: rect.y,
zIndex: 11,
}}>
{annotation.text}
</div>
)}
// Custom overlay (SVG paths, highlights, drawings — works on PDF and images)
renderOverlay={({ width, height, originalWidth, originalHeight, pageNumber, rotation }) => (
<svg
width={width}
height={height}
viewBox={`0 0 ${originalWidth} ${originalHeight}`}
preserveAspectRatio="none"
style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'all' }}
>
<path d="..." stroke="cyan" fill="none" />
</svg>
)}
// Custom renderers (all optional)
renderLoading={() => <MySpinner />}
renderError={(error) => <MyError message={error.message} />}
renderUnsupported={(mimeType) => <div>Cannot preview {mimeType}</div>}
// Layout
className="..."
style={{ width: '100%', height: '100%' }}
/>
```
---
## Tips & tricks
**Reset page, zoom and rotation when the source changes.**
The component does not reset these automatically when you switch files — do it yourself:
```ts
const navigateToFile = (file: FileInfo) => {
setSelectedFile(file);
setPage(1);
setZoom(1);
setRotation(0);
};
```
**Gate pagination UI on `numPages > 0`.**
`onDocumentLoad` only fires for PDFs. Don't render pagination controls until you know there are pages to paginate:
```tsx
{numPages > 0 && (
<>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}></button>
<span>{page} / {numPages}</span>
<button disabled={page >= numPages} onClick={() => setPage(p => p + 1)}></button>
</>
)}
```
**Annotation click → navigate to linked file.**
`annotation.linkedResource` contains the `space` and `externalId` of the linked CDF instance. Match it against `file.instanceId` to navigate:
```ts
onAnnotationClick={(annotation) => {
if (!annotation.linkedResource) return;
const { space, externalId } = annotation.linkedResource;
const linked = files.find(
f => f.instanceId?.space === space && f.instanceId?.externalId === externalId
);
if (linked) navigateToFile(linked);
}}
```
**Touch support is built in.** Two-finger pinch-to-zoom and two-finger drag-to-pan work on touch devices automatically. No configuration needed.
**Pan is middle-click drag** (when zoomed in) on desktop. Left-click remains free for annotation clicks and text selection.
**Ctrl/Cmd + wheel zooms toward the cursor** — also built in. Wire `zoom`/`onZoomChange` if you want programmatic zoom buttons or to persist zoom state; otherwise it works fully uncontrolled.
**`renderOverlay` receives original page dimensions** (`originalWidth`, `originalHeight`) so you can set up an SVG `viewBox` in the original coordinate space. Paths drawn in PDF-point or image-pixel coordinates will map correctly to the rendered page at any zoom level.
---
## Common pitfalls
| Problem | Cause | Fix |
|---|---|---|
| `Failed to resolve module specifier 'pdf.worker.mjs'` | pdfjs-dist not hoisted (pnpm) | Add `public-hoist-pattern[]=pdfjs-dist` to `.npmrc`, or `pnpm add pdfjs-dist` directly |
| `API version does not match Worker version` | `pdfjs-dist` version mismatch between app and `react-pdf` | Do not install `pdfjs-dist` separately — let `react-pdf` provide it. If already installed, remove it |
| Annotations never show | `instanceId` is `undefined` — annotation overlay is disabled without it | Use `instanceId` source, or fall back and accept no annotations for classic files |
| Annotations show but are empty | File has no `CogniteDiagramAnnotation` edges in CDF | Expected — only P&ID/diagram files synced to the data model have annotations |
| Viewer collapses to zero height | Parent has no explicit height | Set `height` via `style`, `className`, or parent CSS |
@@ -0,0 +1,479 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import type { PageCallback } from 'react-pdf/dist/shared/types.js';
import 'react-pdf/dist/Page/TextLayer.css';
import 'react-pdf/dist/Page/AnnotationLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
import type { CogniteFileViewerProps } from './types';
import { getViewerType } from './mimeTypes';
import { useFileResolver } from './useFileResolver';
import { useDocumentAnnotations } from './useDocumentAnnotations';
import { DocumentAnnotationOverlay } from './DocumentAnnotationOverlay';
import { useViewport, computeBaseWidth } from './useViewport';
// ============================================================================
// Sub-renderers
// ============================================================================
function DefaultLoading() {
return <div style={{ padding: 16, color: '#666' }}>Loading file...</div>;
}
function DefaultError({ error }: { error: Error }) {
return (
<div style={{ padding: 16, color: '#c00' }}>
Failed to load file: {error.message}
</div>
);
}
function DefaultUnsupported({ mimeType }: { mimeType: string | undefined }) {
return (
<div style={{ padding: 16, color: '#666' }}>
Unsupported file type{mimeType ? `: ${mimeType}` : ''}
</div>
);
}
// ---------- Shared blob fetch hook ----------
function useBlobUrl(url: string) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<Error | null>(null);
const objectUrlRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
// Reset state for new URL
setBlobUrl(null);
setError(null);
// Revoke previous blob URL
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
if (cancelled) return;
const newUrl = URL.createObjectURL(blob);
objectUrlRef.current = newUrl;
setBlobUrl(newUrl);
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
});
return () => {
cancelled = true;
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
};
}, [url]);
return { blobUrl, error };
}
// ---------- Image ----------
interface ImageRendererProps
extends Omit<CogniteFileViewerProps, 'source' | 'client' | 'className' | 'style'> {
url: string;
}
function ImageRenderer(props: ImageRendererProps) {
const { url, rotation = 0, fitMode, width: explicitWidth, renderLoading, renderError, renderOverlay } = props;
const { currentZoom, effectivePan, containerDims, viewportRef, cursor, handleMouseDown } =
useViewport(props);
const { blobUrl, error } = useBlobUrl(url);
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null);
// Reset natural size when URL changes
const prevUrlRef = useRef(url);
if (prevUrlRef.current !== url) {
prevUrlRef.current = url;
setNaturalSize(null);
}
const handleLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
setNaturalSize({ width: e.currentTarget.naturalWidth, height: e.currentTarget.naturalHeight });
}, []);
if (error) return renderError ? renderError(error) : <DefaultError error={error} />;
if (!blobUrl) return renderLoading ? renderLoading() : <DefaultLoading />;
const baseWidth = computeBaseWidth(fitMode, explicitWidth, containerDims, naturalSize);
const imgWidth = baseWidth ?? naturalSize?.width;
// Until we know image dimensions, render hidden to measure
if (!imgWidth || !naturalSize) {
return (
<div ref={viewportRef} style={{ overflow: 'hidden' }}>
{renderLoading ? renderLoading() : <DefaultLoading />}
<img
src={blobUrl}
alt=""
style={{ position: 'absolute', visibility: 'hidden', pointerEvents: 'none' }}
onLoad={handleLoad}
/>
</div>
);
}
const imgHeight = imgWidth * (naturalSize.height / naturalSize.width);
const isSwapped = rotation === 90 || rotation === 270;
const visualW = (isSwapped ? imgHeight : imgWidth) * currentZoom;
const visualH = (isSwapped ? imgWidth : imgHeight) * currentZoom;
return (
<div ref={viewportRef} style={{ overflow: currentZoom > 1 ? 'hidden' : 'auto', cursor }} onMouseDown={handleMouseDown}>
<div
style={{
display: 'inline-block',
transform:
effectivePan.x !== 0 || effectivePan.y !== 0
? `translate(${effectivePan.x}px, ${effectivePan.y}px)`
: undefined,
}}
>
<div style={{ width: visualW, height: visualH, position: 'relative' }}>
<img
src={blobUrl}
alt=""
style={{
position: 'absolute',
width: imgWidth * currentZoom,
top: '50%',
left: '50%',
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
}}
onLoad={handleLoad}
/>
{renderOverlay && naturalSize && (
renderOverlay({
width: visualW,
height: visualH,
originalWidth: naturalSize.width,
originalHeight: naturalSize.height,
pageNumber: 1,
rotation,
})
)}
</div>
</div>
</div>
);
}
// ---------- Text ----------
function TextRenderer({ url }: { url: string }) {
const [content, setContent] = useState<string | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then((text) => {
if (!cancelled) setContent(text);
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
});
return () => {
cancelled = true;
};
}, [url]);
if (error) return <DefaultError error={error} />;
if (content === null) return <DefaultLoading />;
return (
<pre
style={{
margin: 0,
padding: 16,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontSize: 13,
lineHeight: 1.5,
fontFamily: 'monospace',
}}
>
{content}
</pre>
);
}
// ============================================================================
// PDF Renderer (with annotation overlay)
// ============================================================================
const PDF_LOAD_ERROR = new Error('Failed to load PDF');
interface PdfRendererProps
extends Omit<CogniteFileViewerProps, 'source' | 'className' | 'style' | 'renderUnsupported'> {
url: string;
instanceId?: { space: string; externalId: string };
}
function PdfRenderer(props: PdfRendererProps) {
const {
url,
instanceId,
client,
showAnnotations = true,
onAnnotationClick,
onAnnotationHover,
renderAnnotationTooltip,
page: controlledPage,
onPageChange,
onDocumentLoad,
width,
rotation = 0,
fitMode,
onLoadProgress,
renderLoading,
renderError,
renderOverlay,
} = props;
// -- Viewport (zoom, pan, wheel, drag) --
const {
currentZoom,
effectivePan,
containerDims,
viewportRef,
cursor,
handleMouseDown,
handlePanChange,
} = useViewport(props);
// -- Page state (controlled + uncontrolled) --
const [internalPage, setInternalPage] = useState(1);
const currentPage = controlledPage ?? internalPage;
const handlePageChange = useCallback(
(newPage: number) => {
setInternalPage(newPage);
onPageChange?.(newPage);
},
[onPageChange],
);
// Reset pan on page change
const handlePanChangeRef = useRef(handlePanChange);
handlePanChangeRef.current = handlePanChange;
useEffect(() => {
handlePanChangeRef.current({ x: 0, y: 0 });
}, [currentPage]);
// -- Page dimensions (for annotation overlay) --
const [pageDims, setPageDims] = useState({ width: 0, height: 0 });
const pageObserverRef = useRef<ResizeObserver | null>(null);
const pageWrapperRef = useCallback((node: HTMLDivElement | null) => {
if (pageObserverRef.current) {
pageObserverRef.current.disconnect();
pageObserverRef.current = null;
}
if (node) {
const measure = () => {
const w = node.clientWidth;
const h = node.clientHeight;
setPageDims((prev) => (prev.width === w && prev.height === h ? prev : { width: w, height: h }));
};
const observer = new ResizeObserver(measure);
observer.observe(node);
measure();
pageObserverRef.current = observer;
}
}, []);
useEffect(() => {
return () => {
pageObserverRef.current?.disconnect();
pageObserverRef.current = null;
};
}, []);
// -- Page natural dimensions (for fitMode='page') --
const [pageNaturalSize, setPageNaturalSize] = useState<{ width: number; height: number } | null>(null);
const handlePageLoadSuccess = useCallback((page: PageCallback) => {
const { originalWidth: w, originalHeight: h } = page;
if (w && h) setPageNaturalSize({ width: w, height: h });
}, []);
// -- Compute base width from fitMode --
const baseWidth = computeBaseWidth(fitMode, width, containerDims, pageNaturalSize);
// -- Annotations --
const annotationsEnabled = showAnnotations && instanceId !== undefined;
const { annotations } = useDocumentAnnotations(
client,
instanceId,
currentPage,
{ enabled: annotationsEnabled },
);
// -- PDF Document callbacks --
const currentPageRef = useRef(currentPage);
currentPageRef.current = currentPage;
const handleLoadSuccess = useCallback(
({ numPages }: { numPages: number }) => {
onDocumentLoad?.({ numPages });
if (currentPageRef.current > numPages) handlePageChange(1);
},
[onDocumentLoad, handlePageChange],
);
return (
<div
ref={viewportRef}
style={{ overflow: currentZoom > 1 ? 'hidden' : 'auto', cursor, height: '100%' }}
onMouseDown={handleMouseDown}
>
<Document
file={url}
onLoadSuccess={handleLoadSuccess}
onLoadProgress={onLoadProgress}
loading={renderLoading ? renderLoading() : <DefaultLoading />}
error={
renderError ? (
renderError(PDF_LOAD_ERROR)
) : (
<DefaultError error={PDF_LOAD_ERROR} />
)
}
>
<div
ref={pageWrapperRef}
style={{
position: 'relative',
display: 'inline-block',
transform: effectivePan.x !== 0 || effectivePan.y !== 0
? `translate(${effectivePan.x}px, ${effectivePan.y}px)`
: undefined,
}}
>
<Page
pageNumber={currentPage}
width={baseWidth}
scale={currentZoom}
rotate={rotation}
onLoadSuccess={handlePageLoadSuccess}
/>
{annotationsEnabled && pageDims.width > 0 && annotations.length > 0 && (
<DocumentAnnotationOverlay
annotations={annotations}
containerWidth={pageDims.width}
containerHeight={pageDims.height}
rotation={rotation}
onAnnotationClick={onAnnotationClick}
onAnnotationHover={onAnnotationHover}
renderAnnotationTooltip={renderAnnotationTooltip}
/>
)}
{renderOverlay && pageDims.width > 0 && pageDims.height > 0 && pageNaturalSize && (
renderOverlay({
width: pageDims.width,
height: pageDims.height,
originalWidth: pageNaturalSize.width,
originalHeight: pageNaturalSize.height,
pageNumber: currentPage,
rotation,
})
)}
</div>
</Document>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export const CogniteFileViewer: React.FC<CogniteFileViewerProps> = (props) => {
const {
source,
client,
renderLoading,
renderError,
renderUnsupported,
className,
style,
} = props;
const {
url,
mimeType,
instanceId,
isLoading,
error,
} = useFileResolver(source, client);
const viewerType = getViewerType(mimeType);
const rotation = props.rotation ?? 0;
// -- Loading --
if (isLoading) {
return (
<div className={className} style={style}>
{renderLoading ? renderLoading() : <DefaultLoading />}
</div>
);
}
// -- Error --
if (error || !url) {
return (
<div className={className} style={style}>
{renderError
? renderError(error ?? new Error('No URL resolved'))
: <DefaultError error={error ?? new Error('No URL resolved')} />}
</div>
);
}
// -- Render by type --
const renderContent = () => {
switch (viewerType) {
case 'pdf':
return <PdfRenderer {...props} url={url} instanceId={instanceId} rotation={rotation} />;
case 'image':
return <ImageRenderer {...props} url={url} rotation={rotation} />;
case 'text':
return <TextRenderer url={url} />;
default:
return renderUnsupported ? renderUnsupported(mimeType) : <DefaultUnsupported mimeType={mimeType} />;
}
};
return (
<div className={className} style={style}>
{renderContent()}
</div>
);
};
@@ -0,0 +1,229 @@
import React, { useState } from 'react';
import type { DocumentAnnotation, AnnotationResourceType, BoundingRect } from './types';
// ============================================================================
// Annotation colours (matches cogs.js-v10 design tokens)
// ============================================================================
const ANNOTATION_COLORS: Record<
AnnotationResourceType,
{ stroke: string; hoverFill: string }
> = {
asset: {
stroke: 'rgb(212, 106, 226)',
hoverFill: 'rgba(212, 106, 226, 0.15)',
},
file: {
stroke: 'rgb(255, 135, 70)',
hoverFill: 'rgba(255, 135, 70, 0.15)',
},
timeSeries: {
stroke: 'rgb(164, 178, 252)',
hoverFill: 'rgba(164, 178, 252, 0.15)',
},
sequence: {
stroke: 'rgb(255, 220, 127)',
hoverFill: 'rgba(255, 220, 127, 0.15)',
},
event: {
stroke: 'rgb(253, 81, 144)',
hoverFill: 'rgba(253, 81, 144, 0.15)',
},
diagram: {
stroke: 'rgb(76, 175, 80)',
hoverFill: 'rgba(76, 175, 80, 0.15)',
},
unknown: {
stroke: 'rgb(89, 89, 89)',
hoverFill: 'rgba(89, 89, 89, 0.15)',
},
};
// ============================================================================
// Types
// ============================================================================
export interface DocumentAnnotationOverlayProps {
/** Annotations to render (coordinates are normalised 0-1) */
annotations: DocumentAnnotation[];
/** Rendered page width in CSS pixels */
containerWidth: number;
/** Rendered page height in CSS pixels */
containerHeight: number;
/** Document rotation in degrees (0, 90, 180, 270) */
rotation?: number;
/** Called when a user clicks an annotation */
onAnnotationClick?: (annotation: DocumentAnnotation) => void;
/** Called when a user hovers over / leaves an annotation */
onAnnotationHover?: (annotation: DocumentAnnotation | null) => void;
/** Render a custom tooltip for hovered annotations. Receives the annotation and its pixel-space bounding rect. */
renderAnnotationTooltip?: (
annotation: DocumentAnnotation,
rect: BoundingRect,
) => React.ReactNode;
}
// ============================================================================
// Helpers
// ============================================================================
function getStyle(
resourceType: AnnotationResourceType,
isHovered: boolean,
) {
const colors = ANNOTATION_COLORS[resourceType] ?? ANNOTATION_COLORS.unknown;
return {
stroke: colors.stroke,
fill: isHovered ? colors.hoverFill : 'none',
strokeWidth: isHovered ? 2 : 1.5,
};
}
function transformAnnotation(
annotation: DocumentAnnotation,
w: number,
h: number,
rotation: number,
) {
const { x, y, width, height } = annotation;
switch (rotation) {
case 90:
return {
x: (1 - y - height) * w,
y: x * h,
width: height * w,
height: width * h,
};
case 180:
return {
x: (1 - x - width) * w,
y: (1 - y - height) * h,
width: width * w,
height: height * h,
};
case 270:
return {
x: y * w,
y: (1 - x - width) * h,
width: height * w,
height: width * h,
};
default:
return {
x: x * w,
y: y * h,
width: width * w,
height: height * h,
};
}
}
// ============================================================================
// Component
// ============================================================================
export const DocumentAnnotationOverlay: React.FC<
DocumentAnnotationOverlayProps
> = ({
annotations,
containerWidth,
containerHeight,
rotation = 0,
onAnnotationClick,
onAnnotationHover,
renderAnnotationTooltip,
}) => {
const [hoveredId, setHoveredId] = useState<string | null>(null);
if (annotations.length === 0 || containerWidth === 0 || containerHeight === 0) {
return null;
}
const hoveredAnnotation = hoveredId
? annotations.find((a) => a.id === hoveredId)
: null;
const hoveredRect = hoveredAnnotation
? transformAnnotation(hoveredAnnotation, containerWidth, containerHeight, rotation)
: null;
return (
<>
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'visible',
zIndex: 10,
}}
viewBox={`0 0 ${containerWidth} ${containerHeight}`}
>
{annotations.map((annotation) => {
const isHovered = hoveredId === annotation.id;
const style = getStyle(annotation.resourceType, isHovered);
const rect = transformAnnotation(
annotation,
containerWidth,
containerHeight,
rotation,
);
return (
<rect
key={annotation.id}
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
fill={style.fill}
stroke={style.stroke}
strokeWidth={style.strokeWidth}
rx={1}
ry={1}
style={{ pointerEvents: 'auto', cursor: 'pointer' }}
onMouseEnter={() => {
setHoveredId(annotation.id);
onAnnotationHover?.(annotation);
}}
onMouseLeave={() => {
setHoveredId(null);
onAnnotationHover?.(null);
}}
onClick={(e) => {
e.stopPropagation();
onAnnotationClick?.(annotation);
}}
>
{!renderAnnotationTooltip && annotation.text && (
<title>{annotation.text}</title>
)}
</rect>
);
})}
</svg>
{renderAnnotationTooltip && hoveredAnnotation && hoveredRect && (
renderAnnotationTooltip(hoveredAnnotation, hoveredRect)
)}
</>
);
};
// ============================================================================
// Utilities
// ============================================================================
export function getAnnotationColor(
resourceType: AnnotationResourceType,
): { stroke: string; hoverFill: string } {
return ANNOTATION_COLORS[resourceType] ?? ANNOTATION_COLORS.unknown;
}
export function getAllAnnotationColors(): Record<
AnnotationResourceType,
{ stroke: string; hoverFill: string }
> {
return ANNOTATION_COLORS;
}
@@ -0,0 +1,132 @@
import type { CogniteClient, FileInfo } from '@cognite/sdk';
import {
getComputedMimeType,
isNativelySupportedMimeType,
doesDocumentPreviewApiSupportFile,
DocumentMimeType,
} from './mimeTypes';
// ============================================================================
// Cache
// ============================================================================
/** CDF URLs expire after 60 min with extendedExpiration — refresh at 59 min. */
const URL_CACHE_EXPIRE_MS = 59 * 60 * 1000;
const MAX_CACHE_SIZE = 200;
interface CacheEntry {
url: string;
mimeType: string;
expiresAt: number;
}
const urlCache = new Map<string, CacheEntry>();
/** Evict expired entries; if still over limit, drop oldest inserted. */
function evictStaleEntries(): void {
const now = Date.now();
for (const [key, entry] of urlCache) {
if (entry.expiresAt <= now) urlCache.delete(key);
}
if (urlCache.size > MAX_CACHE_SIZE) {
for (const key of Array.from(urlCache.keys()).slice(0, urlCache.size - MAX_CACHE_SIZE)) {
urlCache.delete(key);
}
}
}
export function clearFileCache(fileId: number, project?: string): void {
const prefix = project ? `${project}:` : '';
urlCache.delete(`${prefix}${fileId}`);
}
export function clearAllFileCache(): void {
urlCache.clear();
}
// ============================================================================
// Download URL helpers
// ============================================================================
/**
* Get download URL with extended expiration (59 min instead of default ~30 min).
* The JS SDK doesn't expose `extendedExpiration`, so we call the API directly.
*/
async function getDownloadUrlExtended(
client: CogniteClient,
fileId: number,
): Promise<string> {
const result = await client.post<{ items: Array<{ downloadUrl: string }> }>(
`/api/v1/projects/${client.project}/files/downloadlink`,
{
data: { items: [{ id: fileId }] },
params: { extendedExpiration: true },
},
);
const downloadUrl = result.data.items[0]?.downloadUrl;
if (!downloadUrl) throw new Error(`No download URL for file ${fileId}`);
return downloadUrl;
}
/**
* Get a temporary PDF link via the Document Preview API.
* Converts Office documents to PDF.
*/
async function getPdfTemporaryLink(
client: CogniteClient,
fileId: number,
): Promise<string> {
const response = await client.documents.preview.pdfTemporaryLink(fileId);
return response.temporaryLink;
}
// ============================================================================
// Main resolution function
// ============================================================================
export interface ResolvedFileConfig {
url: string;
mimeType: string;
}
/**
* Resolve a CDF file to a download URL and effective MIME type.
*
* Strategy:
* 1. Natively supported (images, PDF, text) direct download with extended expiry
* 2. Office documents PDF conversion via Document Preview API
* 3. Otherwise throws
*
* Results are cached for 59 minutes.
*/
export async function resolveFileDownloadConfig(
client: CogniteClient,
file: FileInfo,
): Promise<ResolvedFileConfig> {
const cacheKey = `${client.project}:${file.id}`;
const now = Date.now();
const cached = urlCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return { url: cached.url, mimeType: cached.mimeType };
}
const computedMimeType = getComputedMimeType(file);
let resolved: ResolvedFileConfig;
if (computedMimeType && isNativelySupportedMimeType(computedMimeType)) {
const url = await getDownloadUrlExtended(client, file.id);
resolved = { url, mimeType: computedMimeType };
} else if (doesDocumentPreviewApiSupportFile(file)) {
const url = await getPdfTemporaryLink(client, file.id);
resolved = { url, mimeType: DocumentMimeType.PDF };
} else {
throw new Error(
`Unsupported file type (id: ${file.id}, name: ${file.name}, mimeType: ${file.mimeType})`,
);
}
urlCache.set(cacheKey, { ...resolved, expiresAt: now + URL_CACHE_EXPIRE_MS });
evictStaleEntries();
return resolved;
}
@@ -0,0 +1,40 @@
// Component
export { CogniteFileViewer } from './CogniteFileViewer';
// Annotation overlay (for custom compositions)
export {
DocumentAnnotationOverlay,
getAnnotationColor,
getAllAnnotationColors,
} from './DocumentAnnotationOverlay';
export type { DocumentAnnotationOverlayProps } from './DocumentAnnotationOverlay';
// Hooks (for advanced / custom usage)
export { useFileResolver } from './useFileResolver';
export { useDocumentAnnotations, clearAnnotationCache } from './useDocumentAnnotations';
// File resolution utilities
export { resolveFileDownloadConfig, clearFileCache, clearAllFileCache } from './fileResolution';
// MIME type utilities
export {
getViewerType,
getComputedMimeType,
inferMimeTypeFromUrl,
isNativelySupportedMimeType,
doesDocumentPreviewApiSupportFile,
} from './mimeTypes';
// Types
export type {
FileSource,
FileViewerType,
DocumentAnnotation,
AnnotationResourceType,
BoundingRect,
OverlayRenderInfo,
ResolvedFile,
UseFileResolverResult,
UseDocumentAnnotationsResult,
CogniteFileViewerProps,
} from './types';
@@ -0,0 +1,171 @@
import type { FileViewerType } from './types';
// ============================================================================
// MIME Type Constants
// ============================================================================
export const DocumentMimeType = {
PDF: 'application/pdf',
} as const;
export const ImageMimeType = {
JPEG: 'image/jpeg',
PNG: 'image/png',
SVG: 'image/svg+xml',
TIFF: 'image/tiff',
WEBP: 'image/webp',
} as const;
export const TextMimeType = {
TXT: 'text/plain',
CSV: 'text/csv',
JSON: 'application/json',
} as const;
const NativelySupportedMimeTypes = {
...ImageMimeType,
...DocumentMimeType,
...TextMimeType,
} as const;
type NativelySupportedMimeType =
(typeof NativelySupportedMimeTypes)[keyof typeof NativelySupportedMimeTypes];
// Pre-computed Sets for O(1) lookups (these objects are `as const`, never mutated)
const nativelySupportedSet = new Set<string>(Object.values(NativelySupportedMimeTypes));
const documentMimeSet = new Set<string>(Object.values(DocumentMimeType));
const imageMimeSet = new Set<string>(Object.values(ImageMimeType));
const textMimeSet = new Set<string>(Object.values(TextMimeType));
// ============================================================================
// Document Preview API Support (Office → PDF conversion)
// Source: https://github.com/cognitedata/document-preview
// ============================================================================
const documentPreviewMimeTypes = [
// Word
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
'application/vnd.ms-word.document.macroEnabled.12',
'application/vnd.ms-word.template.macroEnabled.12',
'application/rtf',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.text-template',
// PowerPoint
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
'application/vnd.ms-powerpoint.template.macroEnabled.12',
'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.presentation-template',
// Excel
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
'application/vnd.ms-excel.sheet.macroEnabled.12',
'application/vnd.ms-excel.template.macroEnabled.12',
'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
'application/vnd.apple.numbers',
'text/tab-separated-values',
'application/vnd.oasis.opendocument.spreadsheet',
];
const documentPreviewExtensions = new Set([
'doc', 'dot', 'docx', 'dotx', 'docm', 'dotm', 'rtf', 'odt', 'ott',
'ppt', 'pot', 'pps', 'pptx', 'potx', 'ppsx', 'pptm', 'potm', 'ppsm', 'odp', 'otp',
'xls', 'xlt', 'xlsx', 'xltx', 'xlsm', 'xltm', 'xlsb', 'numbers', 'tsv', 'ods',
]);
const documentPreviewMimeSet = new Set(documentPreviewMimeTypes);
// ============================================================================
// Helpers
// ============================================================================
function getFileExtension(value: string): string {
const clean = value.split('#')[0].split('?')[0];
const filename = clean.split('/').pop() ?? '';
const lastDot = filename.lastIndexOf('.');
if (lastDot <= 0 || lastDot === filename.length - 1) return '';
return filename.slice(lastDot + 1).toLowerCase();
}
function canonicaliseMimeType(mimeType: string): string {
switch (mimeType) {
case 'image/jpg':
return ImageMimeType.JPEG;
case 'image/tif':
return ImageMimeType.TIFF;
case 'image/svg':
return ImageMimeType.SVG;
case 'application/txt':
return TextMimeType.TXT;
default:
return mimeType;
}
}
// ============================================================================
// Public API
// ============================================================================
export function isNativelySupportedMimeType(
mimeType: string | null | undefined,
): mimeType is NativelySupportedMimeType {
if (!mimeType) return false;
return nativelySupportedSet.has(mimeType);
}
export function doesDocumentPreviewApiSupportFile(file: {
mimeType?: string | null;
name?: string | null;
}): boolean {
if (file.mimeType && documentPreviewMimeSet.has(file.mimeType)) return true;
if (file.name && documentPreviewExtensions.has(getFileExtension(file.name))) return true;
return false;
}
const extensionToMimeType: Record<string, string> = {
pdf: DocumentMimeType.PDF,
jpg: ImageMimeType.JPEG,
jpeg: ImageMimeType.JPEG,
png: ImageMimeType.PNG,
svg: ImageMimeType.SVG,
tif: ImageMimeType.TIFF,
tiff: ImageMimeType.TIFF,
webp: ImageMimeType.WEBP,
txt: TextMimeType.TXT,
csv: TextMimeType.CSV,
json: TextMimeType.JSON,
};
export function inferMimeTypeFromUrl(urlOrName: string): string | undefined {
return extensionToMimeType[getFileExtension(urlOrName)];
}
export function getComputedMimeType(file: {
mimeType?: string | null;
name?: string | null;
}): string | undefined {
if (file.mimeType) return canonicaliseMimeType(file.mimeType);
if (file.name) return inferMimeTypeFromUrl(file.name);
return undefined;
}
export function getViewerType(mimeType: string | undefined): FileViewerType {
if (!mimeType) return 'unsupported';
const canonical = canonicaliseMimeType(mimeType);
if (documentMimeSet.has(canonical)) return 'pdf';
if (imageMimeSet.has(canonical)) return 'image';
if (textMimeSet.has(canonical)) return 'text';
// Office documents get converted to PDF
if (documentPreviewMimeSet.has(canonical)) return 'pdf';
return 'unsupported';
}
@@ -0,0 +1,189 @@
import type React from 'react';
import type { CogniteClient, FileInfo } from '@cognite/sdk';
// ============================================================================
// File Source (discriminated union)
// ============================================================================
export type FileSource =
| { type: 'instanceId'; space: string; externalId: string }
| { type: 'url'; url: string; mimeType?: string }
| { type: 'internalId'; id: number };
// ============================================================================
// Viewer Types
// ============================================================================
export type FileViewerType = 'pdf' | 'image' | 'text' | 'unsupported';
// ============================================================================
// Annotations
// ============================================================================
export type AnnotationResourceType =
| 'asset'
| 'file'
| 'timeSeries'
| 'sequence'
| 'event'
| 'diagram'
| 'unknown';
export interface DocumentAnnotation {
id: string;
/** Normalized bounding box (0-1 range relative to page) */
x: number;
y: number;
width: number;
height: number;
/** 1-indexed page number */
page: number;
resourceType: AnnotationResourceType;
linkedResource?: { space: string; externalId: string };
/** Text content (e.g. tag name) */
text?: string;
annotationType: string;
}
// ============================================================================
// Resolved File
// ============================================================================
export interface ResolvedFile {
url: string;
mimeType: string;
fileInfo?: FileInfo;
instanceId?: { space: string; externalId: string };
}
// ============================================================================
// Hook Results
// ============================================================================
export interface UseFileResolverResult extends Partial<ResolvedFile> {
isLoading: boolean;
error: Error | null;
}
export interface UseDocumentAnnotationsResult {
annotations: DocumentAnnotation[];
isLoading: boolean;
error: Error | null;
}
// ============================================================================
// Geometry
// ============================================================================
export interface BoundingRect {
x: number;
y: number;
width: number;
height: number;
}
// ============================================================================
// Overlay
// ============================================================================
export interface OverlayRenderInfo {
/** Rendered page width in CSS pixels (after zoom/scale) */
width: number;
/** Rendered page height in CSS pixels (after zoom/scale) */
height: number;
/** Original page width before zoom (PDF points or image natural pixels) */
originalWidth: number;
/** Original page height before zoom (PDF points or image natural pixels) */
originalHeight: number;
/** Current page number (1-indexed) */
pageNumber: number;
/** Current rotation in degrees */
rotation: 0 | 90 | 180 | 270;
}
// ============================================================================
// Component Props
// ============================================================================
export interface CogniteFileViewerProps {
/** File source — instance ID, direct URL, or CDF internal ID */
source: FileSource;
/** CogniteClient instance (required for instanceId and internalId sources) */
client?: CogniteClient;
// -- Annotations --
/** Show diagram annotations overlay on PDFs (default: true) */
showAnnotations?: boolean;
/** Called when a user clicks an annotation */
onAnnotationClick?: (annotation: DocumentAnnotation) => void;
/** Called when a user hovers over / leaves an annotation */
onAnnotationHover?: (annotation: DocumentAnnotation | null) => void;
/** Render a custom tooltip when hovering an annotation. Receives the annotation and its pixel-space bounding rect. */
renderAnnotationTooltip?: (
annotation: DocumentAnnotation,
rect: BoundingRect,
) => React.ReactNode;
// -- PDF controls --
/** Current page (1-indexed). Uncontrolled if omitted. */
page?: number;
/** Called when the displayed page changes */
onPageChange?: (page: number) => void;
/** Called once the PDF document is loaded */
onDocumentLoad?: (info: { numPages: number }) => void;
/** Desired page width in pixels */
width?: number;
/** Page rotation in degrees */
rotation?: 0 | 90 | 180 | 270;
// -- Zoom & Pan --
/** Current zoom level (1 = 100%). Supports controlled + uncontrolled. */
zoom?: number;
/** Called when zoom changes (Ctrl/Cmd+wheel or pinch) */
onZoomChange?: (zoom: number) => void;
/** Minimum zoom level (default: 0.25) */
minZoom?: number;
/** Maximum zoom level (default: 5) */
maxZoom?: number;
/** Pan offset in pixels. Supports controlled + uncontrolled. Resets on page change. */
panOffset?: { x: number; y: number };
/** Called when pan changes (drag when zoomed in) */
onPanChange?: (offset: { x: number; y: number }) => void;
// -- Fit & Progress --
/** Auto-fit mode: 'width' fits page to container width, 'page' fits entire page in container */
fitMode?: 'width' | 'page';
/** Called during PDF download with progress info */
onLoadProgress?: (progress: { loaded: number; total: number }) => void;
// -- Custom overlay --
/**
* Render custom content (e.g. SVG paths, highlights, drawings) on top of the page.
* The overlay is absolutely positioned over the rendered page.
*
* Provides both rendered dimensions and original (unscaled) page dimensions,
* so consumers can set up an SVG `viewBox` in the original coordinate space:
* ```tsx
* renderOverlay={({ width, height, originalWidth, originalHeight }) => (
* <svg width={width} height={height}
* viewBox={`0 0 ${originalWidth} ${originalHeight}`}
* preserveAspectRatio="none"
* style={{ pointerEvents: 'all' }}>
* <path d="..." />
* </svg>
* )}
* ```
*/
renderOverlay?: (info: OverlayRenderInfo) => React.ReactNode;
// -- Customisation --
/** Override the default loading indicator */
renderLoading?: () => React.ReactNode;
/** Override the default error view */
renderError?: (error: Error) => React.ReactNode;
/** Override the default "unsupported file" view */
renderUnsupported?: (mimeType: string | undefined) => React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
@@ -0,0 +1,268 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import type { CogniteClient, EdgeDefinition } from '@cognite/sdk';
import type {
DocumentAnnotation,
AnnotationResourceType,
UseDocumentAnnotationsResult,
} from './types';
// ============================================================================
// CDM constants
// ============================================================================
const CDM_SPACE = 'cdf_cdm';
const CDM_VERSION = 'v1';
const DIAGRAM_ANNOTATION_VIEW = 'CogniteDiagramAnnotation';
const QUERY_LIMIT = 10_000;
// ============================================================================
// Cache — stores ALL annotations for a file, filtered by page at read time
// ============================================================================
const STALE_TIME = 5 * 60 * 1000; // 5 minutes
const MAX_CACHE_SIZE = 50;
interface CacheEntry {
data: DocumentAnnotation[];
timestamp: number;
}
const annotationCache = new Map<string, CacheEntry>();
/** Cache key scoped by project + file instance. */
function fileCacheKey(project: string, space: string, externalId: string): string {
return JSON.stringify([project, space, externalId]);
}
function evictStaleAnnotations(): void {
const now = Date.now();
for (const [key, entry] of annotationCache) {
if (now - entry.timestamp > STALE_TIME) annotationCache.delete(key);
}
if (annotationCache.size > MAX_CACHE_SIZE) {
for (const key of Array.from(annotationCache.keys()).slice(0, annotationCache.size - MAX_CACHE_SIZE)) {
annotationCache.delete(key);
}
}
}
export function clearAnnotationCache(): void {
annotationCache.clear();
}
// ============================================================================
// Helpers
// ============================================================================
interface CdmAnnotationProps {
status?: string;
startNodeText?: string;
startNodeYMax?: number;
startNodeYMin?: number;
startNodeXMax?: number;
startNodeXMin?: number;
startNodePageNumber?: number;
}
function getResourceType(annotationType: string): AnnotationResourceType {
const lower = annotationType.toLowerCase();
if (lower.includes('asset')) return 'asset';
if (lower.includes('file')) return 'file';
if (lower.includes('timeseries') || lower.includes('time_series'))
return 'timeSeries';
if (lower.includes('sequence')) return 'sequence';
if (lower.includes('event')) return 'event';
if (lower.includes('diagram')) return 'diagram';
return 'unknown';
}
// ============================================================================
// Fetcher — fetches ALL annotations for a file (not per-page)
// ============================================================================
async function fetchAllAnnotations(
client: CogniteClient,
space: string,
externalId: string,
): Promise<DocumentAnnotation[]> {
const containerId = `${space}:${externalId}`;
const propPath = `${DIAGRAM_ANNOTATION_VIEW}/${CDM_VERSION}`;
const allEdges: EdgeDefinition[] = [];
let cursor: string | undefined;
do {
const response = await client.instances.query({
with: {
files: {
nodes: {
filter: {
and: [
{
equals: {
property: ['node', 'externalId'],
value: externalId,
},
},
{
equals: {
property: ['node', 'space'],
value: space,
},
},
],
},
},
},
annotations: {
edges: {
from: 'files',
direction: 'outwards',
},
limit: QUERY_LIMIT,
},
},
select: {
annotations: {
sources: [
{
source: {
externalId: DIAGRAM_ANNOTATION_VIEW,
space: CDM_SPACE,
type: 'view' as const,
version: CDM_VERSION,
},
properties: [
'status',
'startNodeText',
'startNodeYMax',
'startNodeYMin',
'startNodeXMax',
'startNodeXMin',
'startNodePageNumber',
],
},
],
limit: QUERY_LIMIT,
},
},
cursors: cursor ? { annotations: cursor } : undefined,
});
const edges = (response.items?.annotations ?? []).filter(
(a) => a.instanceType === 'edge',
);
allEdges.push(...edges);
cursor =
edges.length < QUERY_LIMIT
? undefined
: response.nextCursor?.annotations;
} while (cursor);
return allEdges.flatMap((edge) => {
const props: CdmAnnotationProps | undefined =
edge.properties?.[CDM_SPACE]?.[propPath];
if (!props) return [];
if (props.status === 'Rejected') return [];
const xMin = Number(props.startNodeXMin ?? 0);
const xMax = Number(props.startNodeXMax ?? 0);
const yMin = Number(props.startNodeYMin ?? 0);
const yMax = Number(props.startNodeYMax ?? 0);
const annotationType =
edge.type?.externalId ?? 'diagrams.AssetLink';
const annotation: DocumentAnnotation = {
id: `${containerId}-${edge.space}-${edge.externalId}`,
x: Math.min(xMin, xMax),
y: Math.min(yMin, yMax),
width: Math.abs(xMax - xMin),
height: Math.abs(yMax - yMin),
page: Number(props.startNodePageNumber ?? 1),
resourceType: getResourceType(annotationType),
linkedResource: edge.endNode
? { space: edge.endNode.space, externalId: edge.endNode.externalId }
: undefined,
text: props.startNodeText ?? undefined,
annotationType,
};
return [annotation];
});
}
// ============================================================================
// Hook
// ============================================================================
interface AnnotationState {
allAnnotations: DocumentAnnotation[];
isLoading: boolean;
error: Error | null;
}
const INITIAL_STATE: AnnotationState = {
allAnnotations: [],
isLoading: false,
error: null,
};
export function useDocumentAnnotations(
client: CogniteClient | undefined,
instanceId: { space: string; externalId: string } | undefined,
currentPage: number = 1,
options?: { enabled?: boolean },
): UseDocumentAnnotationsResult {
const enabled = options?.enabled ?? true;
const [state, setState] = useState<AnnotationState>(INITIAL_STATE);
const cancelRef = useRef(0);
const space = instanceId?.space;
const extId = instanceId?.externalId;
const project = client?.project;
// Fetch all annotations for the file (not per-page)
useEffect(() => {
if (!enabled || !client || !space || !extId || !project) {
setState(INITIAL_STATE);
return;
}
const id = ++cancelRef.current;
const cancelled = () => id !== cancelRef.current;
const key = fileCacheKey(project, space, extId);
const cached = annotationCache.get(key);
if (cached && Date.now() - cached.timestamp < STALE_TIME) {
setState({ allAnnotations: cached.data, isLoading: false, error: null });
return;
}
setState((prev) => ({ ...prev, isLoading: true, error: null }));
fetchAllAnnotations(client, space, extId)
.then((data) => {
if (cancelled()) return;
annotationCache.set(key, { data, timestamp: Date.now() });
evictStaleAnnotations();
setState({ allAnnotations: data, isLoading: false, error: null });
})
.catch((err) => {
if (cancelled()) return;
setState({
allAnnotations: [],
isLoading: false,
error: err instanceof Error ? err : new Error(String(err)),
});
});
}, [client, project, space, extId, enabled]);
// Filter by current page (cheap client-side filter on cached data)
const annotations = useMemo(
() => state.allAnnotations.filter((a) => a.page === currentPage),
[state.allAnnotations, currentPage],
);
return { annotations, isLoading: state.isLoading, error: state.error };
}
@@ -0,0 +1,122 @@
import { useState, useEffect, useRef } from 'react';
import type { CogniteClient } from '@cognite/sdk';
import type { FileSource, UseFileResolverResult } from './types';
import { inferMimeTypeFromUrl } from './mimeTypes';
import { resolveFileDownloadConfig } from './fileResolution';
// ============================================================================
// Helpers
// ============================================================================
function getSourceKey(source: FileSource): string {
switch (source.type) {
case 'instanceId':
return `inst:${source.space}/${source.externalId}`;
case 'internalId':
return `id:${source.id}`;
case 'url':
return `url:${source.url}\0${source.mimeType ?? ''}`;
}
}
const INITIAL: UseFileResolverResult = {
isLoading: true,
error: null,
};
// ============================================================================
// Hook
// ============================================================================
/**
* Resolves a {@link FileSource} to a download URL and MIME type.
*
* - `url` sources are returned directly (no client needed).
* - `internalId` and `instanceId` sources use the CogniteClient to fetch
* metadata and resolve a download URL (with caching).
*/
export function useFileResolver(
source: FileSource,
client?: CogniteClient,
): UseFileResolverResult {
const [result, setResult] = useState<UseFileResolverResult>(INITIAL);
const sourceKey = getSourceKey(source);
const cancelRef = useRef(0);
useEffect(() => {
const id = ++cancelRef.current;
const cancelled = () => id !== cancelRef.current;
async function resolve() {
setResult(INITIAL);
try {
// ----- URL source: no client needed -----
if (source.type === 'url') {
const mimeType = source.mimeType ?? inferMimeTypeFromUrl(source.url);
setResult({
url: source.url,
mimeType: mimeType ?? '',
isLoading: false,
error: null,
});
return;
}
// ----- CDF sources: client is required -----
if (!client) {
throw new Error(
'CogniteClient is required for instanceId and internalId sources',
);
}
// Build the lookup identifier the SDK expects
const idParam =
source.type === 'internalId'
? { id: source.id }
: {
instanceId: {
space: source.space,
externalId: source.externalId,
},
};
const [fileInfo] = await client.files.retrieve([idParam]);
if (cancelled()) return;
const resolved = await resolveFileDownloadConfig(client, fileInfo);
if (cancelled()) return;
// Derive instanceId — prefer the one returned by the API,
// fall back to what the caller passed for instanceId sources.
const instanceId = fileInfo.instanceId
? {
space: fileInfo.instanceId.space,
externalId: fileInfo.instanceId.externalId,
}
: source.type === 'instanceId'
? { space: source.space, externalId: source.externalId }
: undefined;
setResult({
url: resolved.url,
mimeType: resolved.mimeType,
fileInfo,
instanceId,
isLoading: false,
error: null,
});
} catch (err) {
if (cancelled()) return;
setResult({
isLoading: false,
error: err instanceof Error ? err : new Error(String(err)),
});
}
}
resolve();
}, [sourceKey, client]);
return result;
}
@@ -0,0 +1,280 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import type React from 'react';
const ZERO_PAN = { x: 0, y: 0 };
export interface ViewportOptions {
zoom?: number;
onZoomChange?: (zoom: number) => void;
minZoom?: number;
maxZoom?: number;
panOffset?: { x: number; y: number };
onPanChange?: (offset: { x: number; y: number }) => void;
}
/** Get distance between two touch points. */
function getTouchDistance(t1: Touch, t2: Touch): number {
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
return Math.hypot(dx, dy);
}
/** Get midpoint between two touch points. */
function getTouchCenter(t1: Touch, t2: Touch): { x: number; y: number } {
return {
x: (t1.clientX + t2.clientX) / 2,
y: (t1.clientY + t2.clientY) / 2,
};
}
export function useViewport(options: ViewportOptions) {
const {
zoom: controlledZoom,
onZoomChange,
minZoom = 0.25,
maxZoom = 5,
panOffset: controlledPan,
onPanChange,
} = options;
// -- Zoom state (controlled + uncontrolled) --
const [internalZoom, setInternalZoom] = useState(1);
const currentZoom = controlledZoom ?? internalZoom;
const clampZoom = useCallback(
(z: number) => Math.min(maxZoom, Math.max(minZoom, z)),
[minZoom, maxZoom],
);
const handleZoomChange = useCallback(
(newZoom: number) => {
const clamped = clampZoom(newZoom);
setInternalZoom(clamped);
onZoomChange?.(clamped);
},
[onZoomChange, clampZoom],
);
// -- Pan state (controlled + uncontrolled) --
const [internalPan, setInternalPan] = useState(ZERO_PAN);
const currentPan = controlledPan ?? internalPan;
const handlePanChange = useCallback(
(offset: { x: number; y: number }) => {
setInternalPan(offset);
onPanChange?.(offset);
},
[onPanChange],
);
const effectivePan = currentZoom <= 1 ? ZERO_PAN : currentPan;
// -- Stable refs for event handlers --
const currentZoomRef = useRef(currentZoom);
currentZoomRef.current = currentZoom;
const currentPanRef = useRef(currentPan);
currentPanRef.current = currentPan;
const clampZoomRef = useRef(clampZoom);
clampZoomRef.current = clampZoom;
const handleZoomChangeRef = useRef(handleZoomChange);
handleZoomChangeRef.current = handleZoomChange;
const handlePanChangeRef = useRef(handlePanChange);
handlePanChangeRef.current = handlePanChange;
// -- Container dimensions --
const [containerDims, setContainerDims] = useState({ width: 0, height: 0 });
const viewportObserverRef = useRef<ResizeObserver | null>(null);
const eventCleanupRef = useRef<(() => void) | null>(null);
// -- Touch gesture state (stored in ref to avoid re-renders during gesture) --
const touchStateRef = useRef<{
initialDistance: number;
initialZoom: number;
initialPan: { x: number; y: number };
initialCenter: { x: number; y: number };
initialRect: DOMRect;
} | null>(null);
const viewportRef = useCallback((node: HTMLDivElement | null) => {
eventCleanupRef.current?.();
eventCleanupRef.current = null;
viewportObserverRef.current?.disconnect();
viewportObserverRef.current = null;
if (node) {
const measure = () => {
const w = node.clientWidth;
const h = node.clientHeight;
setContainerDims((prev) =>
prev.width === w && prev.height === h ? prev : { width: w, height: h },
);
};
const observer = new ResizeObserver(measure);
observer.observe(node);
measure();
viewportObserverRef.current = observer;
// Ctrl/Cmd + wheel → zoom toward cursor
const wheelHandler = (e: WheelEvent) => {
// Ctrl/Cmd + wheel → zoom toward cursor
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const oldZoom = currentZoomRef.current;
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = clampZoomRef.current(oldZoom * factor);
if (newZoom === oldZoom) return;
const rect = node.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const pan = currentPanRef.current;
const ratio = newZoom / oldZoom;
handleZoomChangeRef.current(newZoom);
handlePanChangeRef.current({
x: cx - (cx - pan.x) * ratio,
y: cy - (cy - pan.y) * ratio,
});
return;
}
// Wheel/trackpad scroll → pan when zoomed in
if (currentZoomRef.current > 1) {
e.preventDefault();
const pan = currentPanRef.current;
handlePanChangeRef.current({
x: pan.x - e.deltaX,
y: pan.y - e.deltaY,
});
}
};
// Touch: pinch-to-zoom + two-finger pan
const touchStartHandler = (e: TouchEvent) => {
if (e.touches.length !== 2) return;
e.preventDefault();
const t1 = e.touches[0];
const t2 = e.touches[1];
touchStateRef.current = {
initialDistance: getTouchDistance(t1, t2),
initialZoom: currentZoomRef.current,
initialPan: { ...currentPanRef.current },
initialCenter: getTouchCenter(t1, t2),
initialRect: node.getBoundingClientRect(),
};
};
const touchMoveHandler = (e: TouchEvent) => {
if (e.touches.length !== 2 || !touchStateRef.current) return;
e.preventDefault();
const t1 = e.touches[0];
const t2 = e.touches[1];
const { initialDistance, initialZoom, initialPan, initialCenter, initialRect } = touchStateRef.current;
// Zoom
const currentDistance = getTouchDistance(t1, t2);
const scale = currentDistance / initialDistance;
const newZoom = clampZoomRef.current(initialZoom * scale);
handleZoomChangeRef.current(newZoom);
// Pan toward pinch center (use cached rect to avoid layout thrashing)
const center = getTouchCenter(t1, t2);
const cx = initialCenter.x - initialRect.left;
const cy = initialCenter.y - initialRect.top;
const ratio = newZoom / initialZoom;
handlePanChangeRef.current({
x: cx - (cx - initialPan.x) * ratio + (center.x - initialCenter.x),
y: cy - (cy - initialPan.y) * ratio + (center.y - initialCenter.y),
});
};
const touchEndHandler = () => {
touchStateRef.current = null;
};
node.addEventListener('wheel', wheelHandler, { passive: false });
node.addEventListener('touchstart', touchStartHandler, { passive: false });
node.addEventListener('touchmove', touchMoveHandler, { passive: false });
node.addEventListener('touchend', touchEndHandler);
node.addEventListener('touchcancel', touchEndHandler);
eventCleanupRef.current = () => {
node.removeEventListener('wheel', wheelHandler);
node.removeEventListener('touchstart', touchStartHandler);
node.removeEventListener('touchmove', touchMoveHandler);
node.removeEventListener('touchend', touchEndHandler);
node.removeEventListener('touchcancel', touchEndHandler);
};
}
}, []);
useEffect(() => {
return () => {
eventCleanupRef.current?.();
viewportObserverRef.current?.disconnect();
};
}, []);
// -- Drag to pan (when zoomed in) --
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef(ZERO_PAN);
const panStart = useRef(ZERO_PAN);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (currentZoomRef.current <= 1) return;
if (e.button !== 1) return; // middle-click only
e.preventDefault();
setIsDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY };
panStart.current = currentPanRef.current;
}, []);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
handlePanChangeRef.current({
x: panStart.current.x + (e.clientX - dragStart.current.x),
y: panStart.current.y + (e.clientY - dragStart.current.y),
});
};
const handleMouseUp = () => setIsDragging(false);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
const cursor = isDragging ? 'grabbing' : currentZoom > 1 ? 'grab' : 'default';
return {
currentZoom,
effectivePan,
containerDims,
viewportRef,
cursor,
handleMouseDown,
handleZoomChange,
handlePanChange,
};
}
export function computeBaseWidth(
fitMode: 'width' | 'page' | undefined,
explicitWidth: number | undefined,
containerDims: { width: number; height: number },
naturalSize: { width: number; height: number } | null,
): number | undefined {
if (!fitMode || containerDims.width <= 0) return explicitWidth;
if (fitMode === 'width') return containerDims.width;
if (fitMode === 'page' && naturalSize && naturalSize.height > 0 && containerDims.height > 0) {
const aspect = naturalSize.width / naturalSize.height;
const containerAspect = containerDims.width / containerDims.height;
return containerAspect > aspect
? containerDims.height * aspect
: containerDims.width;
}
return explicitWidth;
}
@@ -0,0 +1,342 @@
---
name: integrate-fusion-agent
description: >-
Integrates a Flows/Dune app with the Fusion built-in PAIA agent panel using
@cognite/app-sdk. Use this skill whenever a developer wants to: open the
agent panel from their app, send the agent a contextual message, let the
agent read app state (resources), or let the agent call actions in the app.
Triggers: "fusion agent", "PAIA", "agent panel", "sendAgentMessage",
"sendAgentLayoutMode", "agent server", "registerAgentServer",
"connectToHostApp", "agent integration", "agent sidebar", "app-sdk agent".
Always use this skill instead of manually writing agent integration code —
it sets up the correct lifecycle, graceful fallback, and recommended file
structure.
allowed-tools: Read, Glob, Grep, Edit, Write, Bash
---
# Integrate Fusion Agent Panel
Wire a Flows/Dune app into the Fusion built-in PAIA agent using `@cognite/app-sdk`.
There are three independent capabilities — implement only the ones needed:
1. **Open the agent panel** — a button that shows the sidebar/fullscreen agent UI
2. **Send the agent a message** — inject context into the chat (e.g. on item click)
3. **Register an agent server** — expose app state (resources) and actions the agent can call
---
## Step 0 — Understand the app
Before writing any code, read:
- `package.json` — detect package manager and whether `@cognite/app-sdk` is already installed
- `src/App.tsx` (or main entry) — understand current structure, existing SDK usage
Ask the user which of the three capabilities they need if it's not clear from context.
---
## Step 1 — Install the SDK
If `@cognite/app-sdk` is not already in `package.json`, install it:
```shell
pnpm add @cognite/app-sdk # or npm/yarn depending on the app
```
Minimum required version: `0.3.1`
---
## Step 2 — Connect to the host app
All capabilities require a `HostAppAPI` instance. Obtain it once on mount and store it in React state or context. Always catch the rejection — the SDK throws when running outside Fusion (e.g. standalone `vite dev`).
**Pattern for React apps:**
```typescript
// src/hooks/useHostApp.ts
import { useState, useEffect } from 'react';
import { connectToHostApp, type HostAppAPI } from '@cognite/app-sdk';
export function useHostApp(): HostAppAPI | null {
const [api, setApi] = useState<HostAppAPI | null>(null);
useEffect(() => {
connectToHostApp({ applicationName: 'my-app' })
.then(({ api: resolvedApi }) => {
// IMPORTANT: use the updater form here. Comlink proxies are callable
// objects, so setApi(proxy) causes React to invoke the proxy as a
// state-updater function — storing a Promise instead of the proxy.
// setApi(() => proxy) returns the proxy as the new state value.
setApi(() => resolvedApi);
})
.catch(() => {
// Running outside Fusion — agent features disabled, no-op
});
}, []);
return api;
}
```
Call `useHostApp()` at the root of your app and pass `api` down (or put it in context). When `api` is `null`, all agent UI triggers should be hidden or disabled — not shown as broken.
---
## Step 3 — Opening the agent panel
Wire a persistent toolbar button (or equivalent trigger) to `api.sendAgentLayoutMode`.
```typescript
import { type AgentLayoutPayload } from '@cognite/app-sdk';
// Open as sidebar (most common)
await api.sendAgentLayoutMode({ mode: 'sidebar' });
// Other modes
await api.sendAgentLayoutMode({ mode: 'fullscreen' });
await api.sendAgentLayoutMode({ mode: 'closed' });
```
The button should only render when `api` is not null — agent features are unavailable outside Fusion.
```tsx
{api && (
<button onClick={() => api.sendAgentLayoutMode({ mode: 'sidebar' })}>
Open Assistant
</button>
)}
```
---
## Step 4 — Sending the agent a message
Use `sendAgentMessage` on contextual triggers (e.g. "Analyse this item" button). Always pair it with `sendAgentLayoutMode` so the panel is visible.
```typescript
// Open sidebar then inject context
await api.sendAgentLayoutMode({ mode: 'sidebar' });
await api.sendAgentMessage({
message: `Analyse the schedule for "${itemName}" and suggest how to reduce total duration.`,
newSession: true, // clears previous conversation — appropriate for contextual entry points
});
```
Use `newSession: true` when the user is starting a new task from a specific item. Omit it when you want to continue an existing conversation.
The message text should include relevant context the agent can act on immediately — item names, IDs, current state summary.
---
## Step 5 — Registering an agent server
An agent server exposes **resources** (read-only app state the agent can read) and **actions** (tools the agent can invoke). Register once on mount, unregister on unmount.
### Recommended file structure
Separate concerns so each piece is independently testable:
```
src/features/agent/
agentActions.ts — pure factory: (deps) => Action[]
agentResources.ts — pure factory: (deps) => Resource[]
useAgentServer.ts — useEffect lifecycle hook; calls the factories and registers
```
### Resources
Resources are the agent's window into app state. Write `description` as you would a function docstring — the agent reads it to decide when to fetch the resource.
```typescript
// src/features/agent/agentResources.ts
import { createAgentResource } from '@cognite/app-sdk';
import type { StorageService } from '../storage/StorageService';
export function buildAgentResources(storage: StorageService) {
return [
createAgentResource({
uri: 'my-app://current-state',
name: 'Current application state',
description:
'The current list of items visible in the app, their statuses, and any active filters. Read this before answering questions about what the user is looking at.',
async read() {
const data = storage.getAll();
return [{ type: 'json', data }];
},
}),
];
}
```
Each resource's `read()` returns an array of content parts:
- `{ type: 'json', data: unknown }` — structured data (preferred; agent reasons over it directly)
- `{ type: 'text', text: string }` — free-form text
### Actions
Actions are tools the agent can invoke. Use `snake_case` names and Zod for parameter schemas. The `.describe()` on each field is the agent's documentation.
```typescript
// src/features/agent/agentActions.ts
import { createAgentAction } from '@cognite/app-sdk';
import { z } from 'zod';
import type { DataService } from '../data/DataService';
export function buildAgentActions(dataService: DataService) {
return [
createAgentAction({
name: 'get_item_details',
description: 'Retrieve full details for a specific item by ID. Returns all fields including history.',
parameters: z.object({
item_id: z.string().describe('The ID of the item to retrieve'),
}),
async handler({ item_id }) {
const item = await dataService.getItem(item_id);
return { content: [{ type: 'json', data: item }] };
},
}),
];
}
```
**Mutating actions:** The agent does NOT ask the user for confirmation before calling actions — so use caution with actions that write data. Be explicit in the `description` that the action is destructive, and require the user to have approved before the agent calls it.
```typescript
createAgentAction({
name: 'update_item_status',
description:
'Update the status of an item. Call this ONLY when the user has explicitly approved the change. The UI updates immediately.',
parameters: z.object({
item_id: z.string().describe('The item to update'),
status: z.enum(['active', 'closed', 'pending']).describe('The new status'),
}),
async handler({ item_id, status }) {
storage.updateStatus(item_id, status);
return { content: [{ type: 'json', data: { success: true } }] };
},
})
```
### Lifecycle hook
```typescript
// src/features/agent/useAgentServer.ts
import { useEffect } from 'react';
import { createAgentServer, registerAgentServer, type HostAppAPI } from '@cognite/app-sdk';
import { buildAgentActions } from './agentActions';
import { buildAgentResources } from './agentResources';
import { useStorageService } from '../storage/StorageServiceContext';
import { useDataService } from '../data/DataServiceContext';
export function useAgentServer(api: HostAppAPI | null): void {
const storage = useStorageService();
const dataService = useDataService();
useEffect(() => {
if (!api) return;
const server = createAgentServer({
uri: 'my-app', // namespaced by Fusion with instance ID — no need to be globally unique
actions: buildAgentActions(dataService),
resources: buildAgentResources(storage),
});
void registerAgentServer(api, server).catch((err: unknown) => {
console.warn('[agent] registerAgentServer failed:', err);
});
return () => {
void api.unregisterAgentServer('my-app').catch((err: unknown) => {
console.warn('[agent] unregisterAgentServer failed:', err);
});
};
}, [api, storage, dataService]);
}
```
Call `useAgentServer(api)` near the root of your component tree, after `api` is available.
---
## Step 6 — Wire it all together
Call `useHostApp()` at the root, pass `api` to `useAgentServer`, and thread it down to any UI triggers:
```tsx
// src/App.tsx
function App() {
const api = useHostApp();
useAgentServer(api); // registers resources + actions when api is ready
return (
<AppLayout>
<MainContent />
{api && (
<ToolbarButton onClick={() => api.sendAgentLayoutMode({ mode: 'sidebar' })}>
Open Assistant
</ToolbarButton>
)}
</AppLayout>
);
}
```
---
## Dev vs. production
| Environment | `connectToHostApp` | Effect |
|---|---|---|
| Inside Fusion | Resolves with `{ api }` | All features work |
| Standalone `vite dev` | Rejects | Agent features silently disabled |
This is handled by the `useHostApp` hook above — no extra conditionals needed elsewhere.
---
## Testing
Because `buildAgentActions` and `buildAgentResources` are pure factories that accept services as arguments, test them directly without mounting React:
```typescript
// agentActions.test.ts
const mockDataService = { getItem: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }) };
const [getItemAction] = buildAgentActions(mockDataService);
const result = await getItemAction.handler({ item_id: '1' });
expect(result.content[0].data).toEqual({ id: '1', name: 'Test' });
```
---
## Known pitfalls
### `setApi(resolvedApi)` stores a Promise, not the proxy
Comlink proxies are callable objects. React's `useState` setter, when given a function, calls it as `fn(prevState)` to compute the new state. Because a Comlink proxy responds to function calls (forwarding them to the remote), `setApi(proxy)` causes React to invoke the proxy, and the resulting Promise becomes the state value.
**Symptom:** `api` appears non-null (a Promise is truthy), but calling `api.sendAgentLayoutMode(...)` or checking `typeof api.sendAgentLayoutMode` returns nonsense.
**Fix:** Always use the updater form: `setApi(() => resolvedApi)`.
### `typeof proxy.method === 'function'` is always `true`
Comlink Proxy objects return `'function'` for any property access via `typeof`. This means you cannot use `typeof` guards to detect whether a method is actually supported by the host. Use `try/catch` or `.catch()` on the call instead.
---
## Checklist
- [ ] `@cognite/app-sdk@0.3.1+` installed
- [ ] `useHostApp` hook uses `setApi(() => resolvedApi)` — NOT `setApi(resolvedApi)`
- [ ] `useHostApp` hook catches rejection (outside Fusion), stores `api` in state
- [ ] Agent UI buttons only render when `api` is not null
- [ ] `useAgentServer` registered on mount, unregistered on unmount
- [ ] `registerAgentServer` and `unregisterAgentServer` calls have `.catch()` handlers
- [ ] Resource `description` fields explain what data is returned and when to read it
- [ ] Action `name` fields are `snake_case`
- [ ] Mutating actions warn in their `description` that confirmation is required
- [ ] Services injected into action/resource factories (not imported directly) — enables unit testing
+158
View File
@@ -0,0 +1,158 @@
---
name: integrate-todo-list
description: "MUST be used whenever adding a task/todo list feature to a Flows app with Atlas chat. Do NOT manually create todo state management or tool definitions — this skill handles the full module (context, provider, tool, hooks, UI components) and all integration wiring. Prerequisite: integrate-atlas-chat must already be set up. Triggers: todo list, task list, task tracking, TodoWrite, todo panel, task panel, progress tracking, add todos, add tasks."
allowed-tools: Read, Glob, Grep, Edit, Write, Bash
---
# Integrate Todo List
Add a structured task-tracking feature to this Flows app. The agent will use a `TodoWrite` tool
to create and update a task list as it works through multi-step queries, giving the user real-time
visibility into what the agent is doing and why.
**Prerequisite:** **`integrate-atlas-chat`** must already be complete — `useAtlasChat` must be wired (typically from `./atlas-agent/react`), `src/atlas-agent/` must contain the vendored atlas-agent sources, and `@sinclair/typebox`, `ajv`, `ajv-formats` must be installed per that skill.
---
## Step 1 — Read the app
Before writing anything, read:
- `package.json` — confirm `@tabler/icons-react` is installed; if not, install it with the app's package manager
- `src/App.tsx` — find where to add `TodoProvider`
- The file that calls `useAtlasChat` (likely `src/chat/useChatViewModel.ts` or `src/App.tsx`) — this is where the tool gets wired
- The chat view component that renders messages — this is where `TodoPanel` and `TodoToolResultCard` go
---
## Step 2 — Create the `src/todo/` module
Find the skill directory by running `find . -path "*/.agents/skills/integrate-todo-list/code" -type d` from the project root.
Read each file from `<skill-dir>/code/` and write it into `src/todo/` with the same filename:
| File | Purpose |
|------|---------|
| `types.ts` | `TodoItem` and `TodoList` types |
| `TodoContext.tsx` | React context + `TodoProvider` |
| `useTodoList.ts` | Hook to read/write the todo list |
| `todoWriteTool.ts` | `createTodoWriteTool` factory — `AtlasTool` with full CDF task-decomposition guidance |
| `useTodoWriteTool.ts` | Hook that memoizes the tool with current state access |
| `TodoPanel.tsx` | Card UI: progress bar + task rows |
| `TodoItemRow.tsx` | Single row with animated status icons |
| `TodoToolResultCard.tsx` | Compact summary card for tool call display |
All files use relative imports (`./types`, `./TodoContext`, etc.) — no changes needed.
---
## Step 3 — Wrap the app in `TodoProvider`
In `src/App.tsx` (or the root component), wrap the existing tree with `<TodoProvider>`:
```tsx
import { TodoProvider } from './todo/TodoContext'; // adjust path to match app conventions
function App() {
return (
<TodoProvider>
{/* existing children */}
</TodoProvider>
);
}
```
---
## Step 4 — Wire the tool into `useAtlasChat`
In the file that calls `useAtlasChat`, add the following. Adjust import paths to match the app's conventions.
```ts
import { useRef, useCallback } from 'react';
import { useTodoList } from './todo/useTodoList';
import { useTodoWriteTool } from './todo/useTodoWriteTool';
// Inside the hook/component:
const { todos, setTodos } = useTodoList();
const todoWriteTool = useTodoWriteTool();
// Keep a ref so getAppContext always reads fresh state without re-creating the callback.
const todosRef = useRef(todos);
todosRef.current = todos;
const getAppContext = useCallback(() => {
const t = todosRef.current;
if (t.length === 0) return undefined;
const lines = t.map((item, i) => `${i + 1}. [${item.status}] ${item.content}`);
return `Current todo list:\n${lines.join('\n')}`;
}, []);
// Add to useAtlasChat options:
const { messages, send, isStreaming, progress, error, reset, abort } = useAtlasChat({
client: isLoading ? null : sdk,
agentExternalId: AGENT_EXTERNAL_ID,
tools: [todoWriteTool], // add alongside any existing tools
getAppContext,
});
// In the reset handler, clear the todo list:
const handleReset = useCallback(() => {
reset();
setTodos([]);
}, [reset, setTodos]);
// Expose todos in the return value so the view can render TodoPanel:
return { ..., todos };
```
---
## Step 5 — Render `TodoPanel` in the chat view
In the component that renders the chat input area, add `<TodoPanel>` above the input field:
```tsx
import { TodoPanel } from './todo/TodoPanel'; // adjust path
// In the render:
<TodoPanel todos={todos} />
<YourChatInput ... />
```
`TodoPanel` returns `null` when the list is empty, so it's safe to always render it.
---
## Step 6 — Render `TodoToolResultCard` for tool call steps
In the component that renders per-message tool calls (typically a steps accordion or similar), branch on the tool name:
```tsx
import { TodoToolResultCard } from './todo/TodoToolResultCard'; // adjust path
{toolCalls.map((tc, i) =>
tc.name === 'TodoWrite' ? (
<TodoToolResultCard key={i} toolCall={tc} />
) : (
<YourDefaultToolCallCard key={i} toolCall={tc} />
)
)}
```
---
## Step 7 — Verify
Run the app's type-check command (typically `pnpm tsc --noEmit`) and confirm there are no errors.
If the project has tests, run them to confirm nothing regressed.
---
## Done
The agent can now use `TodoWrite` to create and track tasks. It will:
- Show a task panel as soon as it starts multi-step work
- Update task status in real-time (`pending``in_progress``completed`)
- Clear the list automatically when all tasks are done
- Inject the current task list into each prompt via `getAppContext` so it knows where it left off
@@ -0,0 +1,19 @@
import { createContext, useState, useMemo } from 'react';
import type { ReactNode } from 'react';
import type { TodoList } from './types';
export interface TodoStoreValue {
todos: TodoList;
setTodos: (todos: TodoList) => void;
}
export const TodoContext = createContext<TodoStoreValue>({
todos: [],
setTodos: () => undefined,
});
export function TodoProvider({ children }: { children: ReactNode }) {
const [todos, setTodos] = useState<TodoList>([]);
const value = useMemo(() => ({ todos, setTodos }), [todos]);
return <TodoContext.Provider value={value}>{children}</TodoContext.Provider>;
}
@@ -0,0 +1,38 @@
import { IconCircle, IconCircleFilled, IconCircleCheckFilled } from '@tabler/icons-react';
import type { TodoItem } from './types';
interface TodoItemRowProps {
item: TodoItem;
}
const STATUS_ICONS = {
pending: <IconCircle size={13} className="shrink-0 text-muted-foreground/50" />,
in_progress: <IconCircleFilled size={13} className="shrink-0 text-primary animate-pulse" />,
completed: <IconCircleCheckFilled size={13} className="shrink-0 text-success" />,
};
export function TodoItemRow({ item }: TodoItemRowProps) {
const label = item.status === 'in_progress' ? item.activeForm : item.content;
const isInProgress = item.status === 'in_progress';
const isCompleted = item.status === 'completed';
return (
<div
className={[
'flex items-start gap-2.5 rounded-sm px-2 py-1 -mx-2',
isInProgress ? 'bg-primary/5' : '',
].join(' ')}
>
<span className="mt-0.5">{STATUS_ICONS[item.status]}</span>
<span
className={[
'text-sm leading-snug',
isCompleted ? 'text-muted-foreground/60 line-through' : '',
isInProgress ? 'text-foreground font-medium' : 'text-muted-foreground',
].join(' ')}
>
{label}
</span>
</div>
);
}
@@ -0,0 +1,44 @@
import { Badge, Card, CardContent, CardHeader, CardHeaderRight, CardTitle } from '@cognite/aura/components';
import { TodoItemRow } from './TodoItemRow';
import type { TodoList } from './types';
interface TodoPanelProps {
todos: TodoList;
}
export function TodoPanel({ todos }: TodoPanelProps) {
if (todos.length === 0) return null;
const completedCount = todos.filter((t) => t.status === 'completed').length;
const progressPct = Math.round((completedCount / todos.length) * 100);
return (
<div className="mb-3">
<Card>
<CardHeader className="pb-2">
<CardTitle as="h3" className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Tasks
</CardTitle>
<CardHeaderRight>
<Badge variant="secondary" size="default">
{completedCount}/{todos.length}
</Badge>
</CardHeaderRight>
</CardHeader>
<div className="mx-4 h-px bg-border">
<div
className="h-full bg-success transition-all duration-500 ease-out"
style={{ width: `${progressPct}%` }}
/>
</div>
<CardContent className="max-h-40 overflow-y-auto pt-3">
{todos.map((item, i) => (
// Index is safe here: the agent only appends to the end and updates in place — it never reorders or inserts in the middle.
// Using content as a key would cause remounts (and animation resets) whenever the agent updates a task title with discovered node names.
<TodoItemRow key={i} item={item} />
))}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,47 @@
import { Tool, ToolContent, ToolHeader } from '@cognite/aura/components';
interface ToolCall {
name: string;
input?: unknown;
output?: string;
details?: unknown;
}
interface TodoToolResultCardProps {
toolCall: ToolCall;
}
interface TodoDetails {
completed: number;
inProgress: number;
pending: number;
newTodos: { content: string; status: string }[];
}
function isTodoDetails(value: unknown): value is TodoDetails {
return (
typeof value === 'object' &&
value !== null &&
'completed' in value &&
'inProgress' in value &&
'pending' in value
);
}
export function TodoToolResultCard({ toolCall }: TodoToolResultCardProps) {
const details = isTodoDetails(toolCall.details) ? toolCall.details : null;
const total = details ? details.completed + details.inProgress + details.pending : 0;
const summary = details
? `${total} task${total !== 1 ? 's' : ''}: ${details.completed} completed, ${details.inProgress} in progress, ${details.pending} pending`
: 'Todo list updated';
return (
<Tool>
<ToolHeader title="Update task list" type="tool-invocation" />
<ToolContent>
<p className="text-sm text-muted-foreground">{summary}</p>
</ToolContent>
</Tool>
);
}
@@ -0,0 +1,344 @@
import { Type } from '@sinclair/typebox';
import type { AtlasTool } from '../atlas-agent/types';
import type { TodoList } from './types';
const parameters = Type.Object({
todos: Type.Array(
Type.Object({
content: Type.String({ description: 'Imperative form, e.g. "Fix authentication bug"' }),
status: Type.Unsafe<'pending' | 'in_progress' | 'completed'>({
type: 'string',
enum: ['pending', 'in_progress', 'completed'],
description: 'Task status',
}),
activeForm: Type.String({
description: 'Present continuous form, e.g. "Fixing authentication bug"',
}),
}),
{ description: 'The complete, updated todo list. Must include ALL items — do not omit any.' }
),
});
const DESCRIPTION = `Use this tool to create and manage a structured task list when answering questions about industrial assets, equipment, maintenance orders, files, time series, and related data in CDF. This helps you track progress across multi-step queries, and helps the user see what you are doing and why.
## When to Use This Tool
Use this tool proactively in these scenarios:
1. Fetching related data across types - Any question that involves traversing from one type to another (e.g., "files for an asset", "time series for a pump", "operations on a maintenance order", "notifications for an asset")
2. Reverse list-relation traversals - Finding CogniteFile, CogniteTimeSeries, or CogniteActivity instances that reference a known asset or equipment requires a separate /search step with containsAny this cannot be done in a single /query call
3. Multi-level traversals - When the question requires stepping through more than one relation (e.g., asset equipment time series)
4. User asks multiple questions - When the user asks for several different pieces of data at once
5. After receiving new instructions - Immediately capture user requirements as todos
6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time
7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during execution
## When NOT to Use This Tool
Skip using this tool when:
1. The question targets a single type with no relation traversal (e.g., "list all assets", "find the maintenance order with ID X")
2. The question is purely conversational or informational (e.g., "what is a functional location?")
3. The answer requires only one tool call
4. You are clarifying what the user wants before starting work
NOTE that you should not use this tool if there is only one trivial step to do. In this case you are better off just doing the task directly.
## Why Multi-Step Decomposition Is Needed for Cross-Type Queries
In CDF Data Modeling, data is organized into separate views (CogniteAsset, CogniteFile, CogniteEquipment, CogniteTimeSeries, CogniteActivity, CogniteMaintenanceOrder, CogniteOperation, etc.). Relations between views are stored as direct relation properties on one side of the relationship.
### Reverse Direct Relation Constraint
A critical constraint governs how relations can be traversed:
**Single-target relations (targetsList=false)** the property points to exactly one instance. Reverse traversal CAN be done via /query using \`through\` + \`direction: inwards\`.
- CogniteAsset.children traverses backward through CogniteAsset.parent (single) works with /query
- CogniteAsset.equipment traverses backward through CogniteEquipment.asset (single) works with /query
- CogniteMaintenanceOrder.operations traverses backward through CogniteOperation.maintenanceOrder (single) works with /query
**List-target relations (targetsList=true)** the property holds a list of references. Reverse traversal CANNOT be done via /query. You MUST use /search with a containsAny filter instead.
- CogniteAsset.files reverse through CogniteFile.assets (list) needs /search + containsAny
- CogniteAsset.timeSeries reverse through CogniteTimeSeries.assets (list) needs /search + containsAny
- CogniteAsset.activities reverse through CogniteActivity.assets (list) needs /search + containsAny
- CogniteEquipment.timeSeries reverse through CogniteTimeSeries.equipment (list) needs /search + containsAny
- CogniteEquipment.activities reverse through CogniteActivity.equipment (list) needs /search + containsAny
- CogniteFile.equipment reverse through CogniteEquipment.files (list) needs /search + containsAny
This means that many natural user questions ("show me files for pump X") require AT LEAST two steps: (1) find the asset/equipment instance, (2) search the related type using containsAny. This is why task decomposition is essential.
### Hierarchy / Path Interpretation
Use the asset \`path\` property to read and interpret the ancestry of a known node. Do not infer hierarchy from alphabetical ordering or from an item's position in a result list.
- \`path[-1]\` = current node
- \`path[-2]\` = parent
- \`path[-3]\` = grandparent
- \`path[-4]\` = ancestor three levels above the current node
Example:
- Path: \`WMT:VAL -> WMT:23 -> WMT:230900 -> WMT:23-1ST STAGE COMPRESSION-PH -> WMT:23-XX-9105\`
- \`path[-4] = WMT:23\`, \`path[-3] = WMT:230900\`, \`path[-2] = WMT:23-1ST STAGE COMPRESSION-PH\`, \`path[-1] = WMT:23-XX-9105\`
To **find descendants** N levels below a given node, do NOT filter on the \`path\` array. Instead, traverse the \`parent\` relation one level at a time, with **one todo item per level**:
- Level 1 (children): query assets where \`parent == A\`
- Level 2 (grandchildren): query assets where \`parent\` is any of the level-1 results
- Level 3: query assets where \`parent\` is any of the level-2 results
- and so on until the target depth
Each level must be its own todo item do not collapse multiple levels into a single task.
### Subtree + Maintenance Queries
A very common industrial pattern is "what maintenance work exists for section X and everything below it?" This combines hierarchy traversal with maintenance order (or notification) lookups and always requires multiple steps:
1. Traverse the hierarchy level by level to collect all descendant asset IDs (one todo per level, as above)
2. Search for maintenance orders referencing those assets use \`mainAsset\` if looking for the primary asset on the order (single field, filter directly); use \`assets\` containsAny if looking for any association (list field, needs /search)
3. Optionally fetch operations or notifications for the found maintenance orders
Note: \`mainAsset\` and \`assets\` on \`CogniteMaintenanceOrder\` serve different purposes. \`mainAsset\` is the primary functional location the order is about; \`assets\` is the broader list of associated assets. For most "what work is planned for asset X" questions, querying \`mainAsset\` is the right starting point.
## Examples of When to Use the Todo List
<example>
User: Please give me all the files for 1st stage compressor
Assistant: *Creates todo list with the following items:*
1. Discover relevant views for files and assets in the data model
2. Find the asset instance for "1st stage compressor"
3. Search for files related to the identified asset (using containsAny on CogniteFile.assets)
4. Summarize the files with their names, space, and external IDs
*Begins working on the first task*
<reasoning>
The assistant used the todo list because:
1. This is a cross-type query spanning CogniteAsset and CogniteFile
2. CogniteFile.assets is a list relation (targetsList=true), so reverse traversal requires a separate /search step with containsAny it cannot be done in a single /query call
3. The asset must be identified first before files can be searched
4. Multiple sequential tool calls are needed, making task tracking valuable
</reasoning>
</example>
<example>
User: Show me the time series and recent activities for pump P-101
Assistant: *Searches for the equipment instance P-101*
Found equipment "P-101" in the data model.
*Creates todo list with specific items:*
1. Retrieve full details of equipment P-101
2. Search for time series referencing P-101 (CogniteTimeSeries.equipment is a list relation needs /search + containsAny)
3. Search for activities referencing P-101 (CogniteActivity.equipment is a list relation needs /search + containsAny)
4. Summarize findings with time series names and activity descriptions
*Begins working on the first task*
<reasoning>
The assistant used the todo list because:
1. First, the assistant identified the equipment to understand the scope
2. Both CogniteTimeSeries.equipment and CogniteActivity.equipment are list relations requiring separate /search + containsAny calls neither can be resolved with a single /query traversal
3. Multiple independent search steps are needed, making task tracking valuable
</reasoning>
</example>
<example>
User: List all child assets of the "North Platform" and their associated equipment and maintenance orders
Assistant: *Creates todo list breaking down the query:*
1. Find the "North Platform" asset instance
2. Query child assets using the parent relation (CogniteAsset.parent is a single relation can use /query with through + direction: inwards)
3. Query equipment for each child asset (CogniteEquipment.asset is a single relation can use /query)
4. Find maintenance orders referencing the child assets
5. Compile and present the asset hierarchy with equipment and maintenance information
*Begins working on the first task*
<reasoning>
The assistant used the todo list because:
1. This involves traversing multiple levels of the data model hierarchy
2. Even though parent/child and asset/equipment are single relations (queryable via /query), the overall task still spans many sequential steps across the hierarchy
3. The user wants a comprehensive view across assets, equipment, and maintenance orders
4. Tracking progress prevents missing any branch of the hierarchy
</reasoning>
</example>
<example>
User: What operations and notifications are there for maintenance order MO-2024-001?
Assistant: *Creates todo list:*
1. Find the maintenance order MO-2024-001
2. Query operations for this maintenance order (CogniteOperation.maintenanceOrder is a single relation can use /query with through + direction: inwards)
3. Search for notifications referencing this maintenance order (CogniteNotification.maintenanceOrder is a single relation can use /query)
4. Summarize operations and notifications with their status and descriptions
*Begins working on the first task*
<reasoning>
The assistant used the todo list because:
1. This spans three types: CogniteMaintenanceOrder, CogniteOperation, and CogniteNotification
2. Operations and notifications both require separate query steps after finding the maintenance order
3. Tracking steps ensures both relations are covered and results are organized clearly
</reasoning>
</example>
<example>
User: What is the maintenance backlog for the entire 1st stage compression section?
Assistant: *Creates todo list:*
1. Find the asset instance for "1st stage compression"
2. Query level-1 children (assets where parent == 1st stage compression asset)
3. Query level-2 children (assets where parent is any level-1 result)
4. Query level-3 children (assets where parent is any level-2 result)
5. Search for maintenance orders where mainAsset is any of the collected asset IDs
6. Summarize maintenance orders with their status, priority, and scheduled dates
*Begins working on the first task*
<reasoning>
The assistant used the todo list because:
1. "The entire section" means all descendant assets must be collected first one todo per hierarchy level
2. Once all descendant asset IDs are known, maintenance orders are found by filtering on mainAsset (single indexed field no containsAny needed for this use case)
3. The hierarchy traversal and the maintenance lookup are separate dependent phases, making step-by-step tracking essential
</reasoning>
</example>
<example>
User: Give me all files for assets that are two levels below WMT:23
Assistant: *Creates todo list:*
1. Find the asset instance WMT:23
2. Query level-1 children of WMT:23 (assets where parent == WMT:23)
3. Query level-2 children (assets where parent is any of the level-1 results)
4. Search for files referencing the level-2 assets (CogniteFile.assets is a list relation needs /search + containsAny)
5. Summarize files with their names and external IDs
*Begins working on the first task*
<reasoning>
The assistant used the todo list because:
1. Descendant lookup must be done by stepping through the parent relation one level at a time each level is its own todo item, never collapsed into one task
2. Two levels of traversal means two separate query steps before any file lookup can happen
3. The final file lookup requires a separate /search + containsAny step because CogniteFile.assets is a list relation
4. The chain of dependent steps makes task tracking essential
</reasoning>
</example>
<example>
User: Please give me the seventh descendant of WMT:VAL
Assistant: *Creates todo list:*
1. Find the asset instance WMT:VAL
2. Query level-1 children (assets where parent == WMT:VAL)
3. Query level-2 children (assets where parent is any level-1 result)
4. Query level-3 children (assets where parent is any level-2 result)
5. Query level-4 children (assets where parent is any level-3 result)
6. Query level-5 children (assets where parent is any level-4 result)
7. Query level-6 children (assets where parent is any level-5 result)
8. Query level-7 children (assets where parent is any level-6 result)
9. Summarize the level-7 descendants with space and external ID
*Begins working on the first task*
<reasoning>
The assistant used the todo list because:
1. Seven levels of parent traversal are required each level is a separate query and gets its own todo item
2. Collapsing all traversal steps into one task would hide progress and make it impossible to track which level is being processed
3. Each step depends on the results of the previous one, making explicit tracking essential
</reasoning>
</example>
## Examples of When NOT to Use the Todo List
<example>
User: How many assets do we have?
Assistant: *Uses the aggregate tool on the CogniteAsset view*
Based on the aggregation, you have 1,247 assets in your data model.
<reasoning>
The assistant did not use the todo list because this is a single aggregation query against one view. No cross-type traversal or multi-step decomposition is needed.
</reasoning>
</example>
<example>
User: What is a direct relation in CDF?
Assistant: A direct relation in CDF is a property on a node that references another node by its space and externalId. It creates a typed graph connection stored directly on the source instance, without needing a separate edge object.
<reasoning>
The assistant did not use the todo list because this is an informational request with no data query to execute. The user is asking for an explanation, not for the assistant to retrieve data from CDF.
</reasoning>
</example>
<example>
User: Find the asset named "Main Turbine"
Assistant: *Searches for the asset using a name filter on CogniteAsset*
Found asset "Main Turbine" (externalId: main-turbine-001, space: plant-assets).
<reasoning>
The assistant did not use the todo list because this is a single search operation against one view. The result is returned directly with no additional steps needed.
</reasoning>
</example>
<example>
User: Does equipment EP-200 exist?
Assistant: *Queries the CogniteEquipment view for EP-200*
Yes, equipment EP-200 exists in space "industrial-assets" with name "Centrifugal Pump EP-200".
<reasoning>
The assistant did not use the todo list because this is a single instance lookup that can be completed in one step. No multi-step decomposition is needed.
</reasoning>
</example>
## Task States and Management
1. **Task States**: Use these states to track progress:
- pending: Task not yet started
- in_progress: Currently working on (limit to ONE task at a time)
- completed: Task finished successfully
**IMPORTANT**: Task descriptions must have two forms:
- content: The imperative form describing what needs to be done (e.g., "Find the asset instance for 1st stage compressor", "Search for files referencing the asset")
- activeForm: The present continuous form shown during execution (e.g., "Finding the asset instance for 1st stage compressor", "Searching for files referencing the asset")
2. **Task Management**:
- Update task status in real-time as you work
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
- Exactly ONE task must be in_progress at any time (not less, not more)
- Complete current tasks before starting new ones
- Remove tasks that are no longer relevant from the list entirely
- **Always mark the final task as completed before delivering your answer** do not give the response and stop without updating the todo list. The last tool call before responding to the user must be a TodoWrite that marks the final task completed.
- **Update pending task titles with discovered node names**: after completing a step that returns concrete instances, rewrite the titles of downstream pending tasks to reflect what was actually found. If there are many results, use the short form: "WMT:23, WMT:24, WMT:25 … (12 total)". For example, once level-1 children are known, change "Query level-2 children (assets where parent is any level-1 result)" to "Query level-2 children of WMT:23, WMT:24 … (4 total)".
3. **Task Completion Requirements**:
- ONLY mark a task as completed when you have FULLY accomplished it
- If a query returns errors or unexpected results, keep the task as in_progress
- When blocked, create a new task describing what needs to be resolved
- Never mark a task as completed if:
- The query returned an error
- You received partial or empty results when data was expected
- You need to retry with a different approach or filter
- Required views or instances were not found
4. **Task Breakdown**:
- Create specific, actionable items
- Break complex queries into smaller, focused steps
- Use clear, descriptive task names
- Always provide both forms:
- content: "Search for files referencing asset X"
- activeForm: "Searching for files referencing asset X"
When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.`;
export interface TodoWriteToolDeps {
getTodos: () => TodoList;
setTodos: (todos: TodoList) => void;
}
export function createTodoWriteTool(deps: TodoWriteToolDeps): AtlasTool<typeof parameters> {
return {
name: 'TodoWrite',
description: DESCRIPTION,
parameters,
execute: (args) => {
const oldTodos = deps.getTodos();
const allDone = args.todos.every((t) => t.status === 'completed');
const newTodos = allDone ? [] : args.todos;
deps.setTodos(newTodos);
const completed = args.todos.filter((t) => t.status === 'completed').length;
const inProgress = args.todos.filter((t) => t.status === 'in_progress').length;
const pending = args.todos.filter((t) => t.status === 'pending').length;
return {
output:
'Todos have been modified successfully. Ensure that you continue to use the todo list ' +
'to track your progress. Please proceed with the current tasks if applicable.',
details: { oldTodos, newTodos: args.todos, completed, inProgress, pending },
};
},
};
}
@@ -0,0 +1,7 @@
export interface TodoItem {
content: string;
status: 'pending' | 'in_progress' | 'completed';
activeForm: string;
}
export type TodoList = TodoItem[];
@@ -0,0 +1,7 @@
import { useContext } from 'react';
import { TodoContext } from './TodoContext';
import type { TodoStoreValue } from './TodoContext';
export function useTodoList(): TodoStoreValue {
return useContext(TodoContext);
}
@@ -0,0 +1,17 @@
import { useRef, useMemo } from 'react';
import { useTodoList } from './useTodoList';
import { createTodoWriteTool } from './todoWriteTool';
export function useTodoWriteTool() {
const { todos, setTodos } = useTodoList();
// Keep a ref so the memoized execute closure always reads current state.
const todosRef = useRef(todos);
todosRef.current = todos;
return useMemo(
() => createTodoWriteTool({ getTodos: () => todosRef.current, setTodos }),
// setTodos is stable (from useMemo in TodoProvider), so the tool identity is stable.
[setTodos]
);
}
@@ -0,0 +1,130 @@
---
name: migrate-app-to-flows
description: "MUST be used when migrating a legacy Dune app to the new Flows app hosting infrastructure. Orchestrates the full migration: audits current state, updates app.json to appsApi infra, delegates auth wiring to setup-flows-auth, creates or updates manifest.json network permissions, and updates deploy scripts to @cognite/cli. Use this whenever a user says 'migrate to Flows', 'migrate to new infra', 'move from dune to flows', 'migrate legacy app', or wants to move their existing app to the new Flows app hosting."
allowed-tools: Read, Glob, Grep, Edit, Write, Bash
metadata:
argument-hint: ""
---
# Migrate App to Flows Infrastructure
Orchestrates the full migration of a legacy Dune app to the new Flows app hosting (`appsApi`). Works through each area in order, skipping any already in the correct state.
## Step 1 — Audit current state
Read `app.json`, `package.json`, `vite.config.ts`, and `manifest.json` (if present).
Report a concise summary before making any changes:
```
Migration audit:
✗ app.json: missing infra field → will add "infra": "appsApi"
✗ Auth: DuneAuthProvider in use → will run setup-flows-auth
✗ manifest.json: missing → will create
✓ Deploy script: already uses @cognite/cli
```
Then proceed through Steps 25.
---
## Step 2 — Update `app.json`
If `infra` is already `"appsApi"`, skip this step. Otherwise, add or update the field:
```json
{
"name": "My App",
"externalId": "my-app",
"versionTag": "0.0.1",
"infra": "appsApi",
"deployments": [...]
}
```
---
## Step 3 — Set up Flows auth
Run the `setup-flows-auth` skill now. It handles everything auth-related: package installation, Vite plugin updates, entry file changes, and wiring up `connectToHostApp`.
---
## Step 4 — Create or update `manifest.json`
The Flows host uses `manifest.json` to enforce a Content Security Policy for the app. It must exist at the repo root.
**Create if missing:**
```json
{
"manifestVersion": 1,
"permissions": {
"network": []
}
}
```
**Populate network permissions** by scanning for outbound calls to external domains:
```bash
grep -rn "fetch\|axios\|new XMLHttpRequest" src/ --include="*.ts" --include="*.tsx"
```
For each group of external URLs found, add an entry to the `network` array using the `sources`/`directives` shape:
```json
{
"manifestVersion": 1,
"permissions": {
"network": [
{
"sources": ["https://api.example.com", "https://maps.googleapis.com"],
"directives": ["connect-src"]
}
]
}
}
```
Rules:
- Use full origin (scheme + hostname) in `sources`, not just the hostname.
- `"connect-src"` covers `fetch`/`XMLHttpRequest`. Use `"img-src"` for image URLs, `"font-src"` for fonts.
- The CDF cluster URL is allowed automatically; do not list it.
- If no external calls exist, leave `"network": []`.
- Flag any dynamic URLs the user needs to verify manually.
---
## Step 5 — Update deploy scripts
Replace any `dune deploy` or `npx @cognite/dune` commands in `package.json`:
```json
{
"scripts": {
"deploy": "npx @cognite/cli@latest apps deploy --interactive --published",
"deploy-preview": "npx @cognite/cli@latest apps deploy --interactive"
}
}
```
Keep all other scripts (`start`, `build`, `test`, etc.) unchanged.
---
## Step 6 — Final check
```bash
grep -rn "DuneAuthProvider\|useDune\|@cognite/dune" src/ vite.config.ts 2>/dev/null
```
List any remaining hits for the user to resolve. Then report:
```
Migration complete:
✓ app.json: infra set to "appsApi"
✓ Auth: setup-flows-auth applied
✓ manifest.json: network permissions set
✓ Deploy scripts: updated to @cognite/cli
```
+525
View File
@@ -0,0 +1,525 @@
---
name: performance
description: "MUST be used whenever fixing performance issues in a Flows app. This skill finds AND fixes performance problems — re-renders, inefficient queries, missing pagination, unbounded fetches, large bundles, and memory leaks. It does not just report them. Always measure before and after. Triggers: performance, slow, laggy, optimize, re-render, bundle size, load time, CDF query, large list, memory leak, debounce, virtualize, lazy load, code split."
allowed-tools: Read, Glob, Grep, Shell, Write
metadata:
argument-hint: "[file, component, or area to optimize — e.g. 'src/components/AssetTable.tsx']"
---
# Performance Fix
Systematically find and fix performance issues in **$ARGUMENTS** (or the whole app if no argument is given). Always measure first — never optimize blindly.
---
## Step 1 — Measure baseline before touching anything
Run the production build and capture metrics before making any changes:
```bash
pnpm run build
pnpm run preview
```
Open the app in Chrome and capture:
- **Lighthouse score** (Performance tab → Run audit)
- **React Profiler** (React DevTools → Profiler → Record an interaction)
- Note the components with the longest render times and highest render counts
Record baseline numbers. Every fix must be measured against these.
---
## Step 2 — Find and fix unnecessary re-renders
Read the component tree (start from `src/App.tsx`) and search for these patterns:
```bash
grep -rn --include="*.tsx" \
-E "value=\{\{|onClick=\{\(\)" src/
```
For each instance found, **apply the fix directly**:
**Inline object/array creation in JSX → wrap with `useMemo`:**
```tsx
// BAD — new object on every render causes children to re-render
<Chart options={{ color: "red" }} />
// FIX — wrap with useMemo
const chartOptions = useMemo(() => ({ color: "red" }), []);
<Chart options={chartOptions} />
```
**Event handlers recreated on every render → wrap with `useCallback`:**
```tsx
// BAD
<Button onClick={() => doSomething(id)} />
// FIX — wrap with useCallback
const handleClick = useCallback(() => doSomething(id), [id]);
<Button onClick={handleClick} />
```
**Context that changes on every render → memoize the context value:**
```tsx
// BAD — new object reference every render
<MyContext.Provider value={{ user, sdk }}>
// FIX — memoize the context value
const ctxValue = useMemo(() => ({ user, sdk }), [user, sdk]);
<MyContext.Provider value={ctxValue}>
```
Apply `React.memo` to pure presentational components that receive stable props. Do NOT wrap every component — only those confirmed to re-render unnecessarily via the Profiler.
---
## Step 3 — Find and fix DMS query patterns
For **read-heavy** workloads, prefer APIs that hit the **search/Elasticsearch path** (`query` or `search` on instances) rather than `list` paths that stress **Postgres**.
```bash
# Find all DMS instance API calls
grep -rn --include="*.ts" --include="*.tsx" -E "instances\.(list|search|query|aggregate|retrieve)" src/
# Find direct SDK calls to other CDF resources
grep -rn --include="*.ts" --include="*.tsx" -E "\.(assets|timeseries|events|files|sequences|relationships)\.(list|search|retrieve)" src/
```
For each `instances.list` call in a read-heavy path (e.g. populating a table, dropdown, or search results), **rewrite it to use `instances.query`** with the equivalent filter. Preserve the existing filter logic but express it in the query API format:
```ts
// BAD — instances.list hits Postgres, expensive for read-heavy UI
const result = await client.instances.list({
instanceType: "node",
filter: { equals: { property: ["node", "space"], value: "my-space" } },
limit: 100,
});
// FIX — rewrite to instances.query which hits Elasticsearch
const result = await client.instances.query({
with: {
nodes: {
nodes: {
filter: { equals: { property: ["node", "space"], value: "my-space" } },
},
limit: 100,
},
},
select: {
nodes: {},
},
});
```
| API used | When it's correct | When to rewrite |
|----------|-------------------|-----------------|
| `instances.query` | Read with filters that map to Elasticsearch (text, equals, range) | — |
| `instances.search` | Full-text or fuzzy search | — |
| `instances.list` | Writing, syncing, or need for semantics not available on query/search | Rewrite to `instances.query` if used for read-heavy UI display |
| `instances.retrieve` | Fetching by known external IDs | — |
| `instances.aggregate` | Counts, histograms | — |
For deeper rationale on search vs relational paths, cardinality, and materialization tradeoffs, consult the `semantic-knowledge/` directory if available in the workspace.
---
## Step 4 — Find and fix client-side filtering (move to server-side)
Filters, limits, and projections must be applied **in the API request** — not by downloading large result sets and filtering in the browser.
```bash
# Find client-side filtering after data fetch (common anti-pattern)
grep -rn --include="*.ts" --include="*.tsx" -B 5 "\.filter(" src/ | grep -B 5 "data\|items\|result\|response\|nodes"
# Find .map() or .reduce() on full datasets that suggest client-side processing
grep -rn --include="*.ts" --include="*.tsx" -E "\.(map|reduce|find|some|every)\(" src/hooks/ src/services/ src/api/
```
For each client-side filter pattern, **move the filter logic into the SDK call's `filter` parameter and remove the `.filter()` call**:
```ts
// BAD — fetches all nodes then filters client-side
const result = await client.instances.query({ ... });
const activeNodes = result.items.nodes.filter(n => n.properties.status === "active");
// FIX — move filter into the API request, remove client-side .filter()
const result = await client.instances.query({
with: {
nodes: {
nodes: {
filter: {
and: [
existingFilters,
{ equals: { property: ["mySpace", "myView/v1", "status"], value: "active" } },
],
},
},
limit: 100,
},
},
select: { nodes: {} },
});
const activeNodes = result.items.nodes; // no client-side filter needed
```
| Issue | Fix |
|-------|-----|
| `.filter()` after SDK call on full result set | Move the filter into the API request's `filter` parameter and delete the `.filter()` |
| No `properties` selection in DMS query | Add a `sources` or `properties` parameter to fetch only needed fields |
| Fetching all items then rendering a subset | Add `limit` and `filter` to the API call to fetch only what's displayed |
| Client-side text search on fetched array | Replace with the SDK's `search` endpoint |
**Hard rule:** If the API supports a filter for the criterion being applied client-side, **move it server-side now**. Client-side filtering is acceptable only for trivial local state (e.g. filtering a cached list of 10 user preferences). If the API does not support the exact filter, add a code comment explaining why client-side filtering is necessary.
---
## Step 5 — Find and fix CDF data fetching and pagination
Read all CDF SDK calls (search for `sdk.`, `client.`, `useQuery`, `useCogniteClient`).
```bash
# Find pagination patterns
grep -rn --include="*.ts" --include="*.tsx" -E "(nextCursor|cursor|hasNextPage|fetchNextPage|offset|skip|page)" src/
# Find "fetch all" loops
grep -rn --include="*.ts" --include="*.tsx" -B 3 -A 3 "while.*cursor\|while.*hasMore\|while.*nextPage" src/
```
For each call, find the issue and **apply the fix**:
| Issue | Fix to apply |
|-------|-------------|
| No `limit` set | **Add `limit: 100`** (or the actual page size needed) to the SDK call |
| Fetching all properties | **Add a `properties` filter** to select only required fields |
| Fetching on every render | **Move inside `useQuery`/`useMemo`** with a stable dependency array |
| Sequential requests that could be parallel | **Rewrite to `Promise.all`** or batched SDK methods |
| Missing `limit` parameter | **Add explicit `limit`** matching the UI's page size (e.g. 25, 50, 100) |
| Offset-based pagination for large datasets | **Replace with cursor-based pagination** using `nextCursor` from the response |
| "Fetch all" loop (exhausts cursors up front) | **Replace with on-demand pagination** using TanStack Query's `useInfiniteQuery` |
**Fixing fetch-all loops** — replace the while loop with `useInfiniteQuery`:
```ts
// BAD — fetches ALL pages before rendering
let allItems = [];
let cursor = undefined;
while (true) {
const result = await client.instances.list({ limit: 1000, cursor });
allItems.push(...result.items);
if (!result.nextCursor) break;
cursor = result.nextCursor;
}
// FIX — paginate on demand with useInfiniteQuery
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["instances", filters],
queryFn: ({ pageParam }) =>
client.instances.list({ limit: 100, cursor: pageParam, ...filters }),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
staleTime: 30_000,
});
```
**Fixing offset-based pagination** — switch to cursor-based:
```ts
// BAD — offset pagination degrades at scale
const result = await client.instances.list({ limit: 100, offset: page * 100 });
// FIX — cursor-based pagination
const result = await client.instances.list({ limit: 100, cursor: nextCursor });
```
---
## Step 6 — Find and fix excessive API call rates
```bash
# Find search/filter inputs that trigger queries
grep -rn --include="*.tsx" --include="*.ts" -E "onChange|onInput|onSearch|onFilter" src/ | grep -i "search\|filter\|query"
# Find debounce usage
grep -rn --include="*.ts" --include="*.tsx" -i -E "debounce|useDebouncedValue|useDebounce" src/
# Find polling/interval patterns
grep -rn --include="*.ts" --include="*.tsx" -E "setInterval|refetchInterval|pollingInterval|refetchOnWindowFocus" src/
# Find useQuery options that control refetch behavior
grep -rn --include="*.ts" --include="*.tsx" -E "staleTime|cacheTime|gcTime|refetchOnMount|refetchOnWindowFocus" src/
```
For each issue found, **apply the fix**:
**Search inputs that fire on every keystroke → add debounce with 300ms delay:**
```tsx
// BAD — fires API call on every keystroke
const [search, setSearch] = useState("");
const { data } = useQuery({ queryKey: ["search", search], queryFn: () => api.search(search) });
// FIX — create or use a useDebouncedValue hook with 300ms delay
function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 300);
const { data } = useQuery({
queryKey: ["search", debouncedSearch],
queryFn: () => api.search(debouncedSearch),
enabled: debouncedSearch.length > 0,
});
```
**useQuery calls without staleTime → add appropriate staleTime:**
```ts
// BAD — refetches on every mount/focus
useQuery({ queryKey: ["data"], queryFn: fetchData });
// FIX — add staleTime to prevent unnecessary refetches
useQuery({ queryKey: ["data"], queryFn: fetchData, staleTime: 30_000 });
```
**Duplicate parallel identical requests → lift the query to a shared hook:**
```ts
// BAD — multiple components each call the same query independently
// ComponentA.tsx: useQuery({ queryKey: ["assets"], queryFn: fetchAssets });
// ComponentB.tsx: useQuery({ queryKey: ["assets"], queryFn: fetchAssets });
// FIX — create a shared hook, import it from both components
// hooks/useAssets.ts
export function useAssets() {
return useQuery({ queryKey: ["assets"], queryFn: fetchAssets, staleTime: 30_000 });
}
```
| Issue | Fix to apply |
|-------|-------------|
| Search input fires query on every keystroke | **Add `useDebouncedValue` hook** with 300ms delay |
| Polling with no backoff or very short interval | **Set interval to ≥30s** with exponential backoff on errors |
| Re-fetching on every render (no caching) | **Add `staleTime: 30_000`** (or appropriate) to useQuery options |
| `refetchOnWindowFocus: true` for expensive queries | **Set `refetchOnWindowFocus: false`** or use a longer stale time |
| Duplicate parallel identical requests | **Lift the query to a shared hook** and import from both components |
| Multiple components triggering the same fetch | **Extract to a shared hook** in `hooks/` directory |
---
## Step 7 — Find and fix large un-virtualized lists
Search for lists that render more than ~50 items:
```bash
grep -rn --include="*.tsx" -E "\.(map|forEach)\(" src/
```
For any list where the data source could exceed 50 items, **replace the plain `.map()` render with a virtualized list**. Install `@tanstack/react-virtual` if not present:
```bash
pnpm add @tanstack/react-virtual
```
**Apply the virtualizer pattern directly:**
```tsx
// BAD — renders all items in the DOM
<div>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
// FIX — replace with virtualized list
import { useVirtualizer } from "@tanstack/react-virtual";
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);
```
---
## Step 8 — Find and fix missing code splitting
Read the router setup and identify routes that are imported statically but not shown on the landing page.
**For each statically imported heavy page, convert to lazy import with `React.lazy()` and `Suspense`:**
```tsx
// BAD — statically imported, loaded in initial bundle
import { ReportPage } from "./pages/ReportPage";
// FIX — convert to lazy import
import { lazy, Suspense } from "react";
const ReportPage = lazy(() => import("./pages/ReportPage"));
// In the route — wrap with Suspense
<Suspense fallback={<PageSkeleton />}>
<ReportPage />
</Suspense>
```
Similarly, large third-party components (chart libraries, PDF viewers, map renderers) should be dynamically imported inside the component that needs them, not at the module level. **Apply the transformation directly** to each heavy import found.
---
## Step 9 — Analyse and fix bundle size
```bash
# Install if not already present, then run
pnpm add -D rollup-plugin-visualizer
```
Add to `vite.config.ts` temporarily:
```ts
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [
react(),
visualizer({ open: true, gzipSize: true, brotliSize: true }),
],
});
```
Run `pnpm run build` and inspect the treemap. For any chunk > 100 KB (gzipped) that is not a necessary initial dependency, **apply the fix**:
| Issue | Fix to apply |
|-------|-------------|
| `lodash` (full bundle) | **Replace with `lodash-es`** individual imports or native equivalents (e.g., `Array.prototype.map`, `Object.entries`, `structuredClone`) |
| `moment` | **Replace with `date-fns`** or native `Intl.DateTimeFormat` |
| Chart libraries not tree-shaken | **Switch to named imports** (e.g., `import { LineChart } from "echarts/charts"`) |
| Large library used in one place | **Dynamically import it** with `React.lazy` or inline `import()` |
```ts
// BAD
import _ from "lodash";
const sorted = _.sortBy(items, "name");
// FIX — use lodash-es or native
import sortBy from "lodash-es/sortBy";
const sorted = sortBy(items, "name");
// OR native:
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
```
```ts
// BAD
import moment from "moment";
const formatted = moment(date).format("YYYY-MM-DD");
// FIX — use date-fns
import { format } from "date-fns";
const formatted = format(date, "yyyy-MM-dd");
```
**After analysis, remove the visualizer plugin** from `vite.config.ts` and uninstall it:
```bash
pnpm remove rollup-plugin-visualizer
```
---
## Step 10 — Find and fix memory leaks
Search for `useEffect` hooks that set up subscriptions, timers, or event listeners without cleanup:
```bash
grep -rn --include="*.tsx" --include="*.ts" -A 10 "useEffect" src/
```
For every `useEffect` that calls `addEventListener`, `setInterval`, `setTimeout`, `subscribe`, or sets up a CDF streaming connection, **add the missing cleanup function**:
**Fetch without abort → add AbortController:**
```ts
// BAD — no cleanup, fetch continues after unmount
useEffect(() => {
fetchData(id);
}, [id]);
// FIX — add AbortController for cleanup
useEffect(() => {
const controller = new AbortController();
fetchData(id, controller.signal);
return () => controller.abort();
}, [id]);
```
**Timer without cleanup → add clearInterval/clearTimeout:**
```ts
// BAD — interval keeps running after unmount
useEffect(() => {
const id = setInterval(() => poll(), 5000);
}, []);
// FIX — add clearInterval cleanup
useEffect(() => {
const id = setInterval(() => poll(), 5000);
return () => clearInterval(id);
}, []);
```
**Event listener without cleanup → add removeEventListener:**
```ts
// BAD — listener accumulates on each render
useEffect(() => {
window.addEventListener("resize", handleResize);
}, []);
// FIX — add removeEventListener cleanup
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
```
---
## Step 11 — Measure after and report the delta
Re-run the same Lighthouse audit and React Profiler session from Step 1. Report the delta and list every file changed:
| Metric | Before | After | Change |
|--------|--------|-------|--------|
| Lighthouse Performance | 72 | 91 | +19 |
| Largest Contentful Paint | 3.2 s | 1.8 s | 1.4 s |
| Total Blocking Time | 420 ms | 80 ms | 340 ms |
| Bundle size (gzipped) | 410 KB | 290 KB | 120 KB |
| `AssetTable` render count (on filter change) | 8 | 2 | 6 |
If a step produced no improvement, state that explicitly. Do not fabricate numbers.
---
## Done
List every file changed with the absolute path and a one-line explanation of what was fixed. If further gains require server-side or infrastructure changes (e.g., CDF response caching, CDN configuration), note them separately as out-of-scope recommendations.
@@ -0,0 +1,95 @@
---
name: pull-changes-resolve-conflicts
description: >-
Standard workflow for pulling updates from main or other branches on multi-contributor
projects (including Flows apps) without silently discarding work. Guides fetching/merging,
requires listing merge conflicts explicitly, analyzing ours vs theirs using conversation
history and repo context, presenting prioritized recommendations, and obtaining user answers
before editing conflict markers or completing the merge. Triggers: pull main, merge main,
merge origin, rebase, merge conflict, unmerged paths, both modified, integrate branch,
sync with main, git merge abort, resolve conflicts, UU status, theirs vs ours, feat branch update.
allowed-tools: Read, Glob, Grep, Shell, Write
metadata:
argument-hint: ""
---
# Pull changes & resolve merge conflicts
Use this skill whenever integrating another branch (usually `main`) into the current feature branch, or when `git status` shows unmerged paths after a merge or rebase. Applies to any Git-based team workflow; Flows/React apps are a common case where conflicts cluster in app shells and shared libraries.
## Goals
- Preserve intentional work on the current branch; **do not assume** “main wins” or “ours wins” without analysis.
- Make trade-offs **visible** to the user **before** any conflict resolution edits.
- Order discussion by **impact**: structural / feature / API / data-model changes before styling, copy, or spacing.
## Hard rules
1. **No silent resolution** — Do not remove `<<<<<<<` / `=======` / `>>>>>>>` or run `git add` on conflicted files until the user has agreed to the plan (or explicitly says “use your recommendations”).
2. **Stop at conflicts** — If a merge or rebase introduces conflicts, **pause** and report; do not bulldoze through large files by picking one side wholesale unless the user explicitly requests that.
3. **Prioritize impact** — When presenting conflicts, group and order roughly as:
- **P0 — Structural / product:** removed routes, deleted modules, dropped features, changed data or API contracts, SDK or schema changes, auth or routing shell.
- **P1 — Behavior:** logic, hooks, queries, filters, error handling, loading states.
- **P2 — UI structure:** layout regions, new or removed sections, navigation.
- **P3 — Presentation:** tokens, spacing, class names, copy tweaks.
## Workflow
### 1. Fetch and integrate (or diagnose)
- Prefer `git fetch` then `git merge origin/main` (or the named branch) unless the user asked for rebase.
- If merge is already in progress, run `git status` and list **every** unmerged file.
### 2. Report conflicts to the user (explicit)
Output a clear list:
- **Branch state:** current branch, target branch (e.g. `origin/main`), merge vs rebase.
- **Unmerged files:** paths only, then optionally `git diff --name-only --diff-filter=U`.
- **Per file (short):** one line on *what* diverged (e.g. “`AlertsPage` — layout + new data scope”) if inferable from paths and `git diff` **without** resolving.
### 3. Analyze before editing
Use **all** of:
- **Conversation history** — What was the user or team trying to ship on this branch?
- **Repo signals** — Product or architecture docs if present (e.g. `PRD.md`), recent commits on the current branch, file ownership (e.g. large feature module vs shared `lib/`).
- **Conflict hunks**`git show :2:path` (ours) vs `git show :3:path` (theirs) during merge, or read conflict markers; identify duplicated vs orthogonal changes.
Classify each conflicted area as:
- **Orthogonal** — safe to combine (e.g. import sort + new prop).
- **Overlapping** — must choose or manually merge (same lines).
- **Corruption risk** — duplicated blocks (common after bad merges); flag and recommend reconstructing from one side then re-applying the other sides intent manually.
### 4. Recommendations + questions (required)
Present to the user:
1. **Summary table or bullets** — file → recommended side *or* “manual merge” → one-line **why**.
2. **Ordered by P0 → P3** — call out anything that **removes** a feature or **changes** public behavior first.
3. **Explicit questions** — anything ambiguous (e.g. “Keep mains global behavior or the branchs scoped variant?”).
4. **Ask for direction** — e.g. “Reply with: (a) follow recommendations, (b) keep branch for file X, (c) keep main for file Y, (d) abort merge.”
**Only after** the user confirms (or gives a precise mapping), apply resolutions:
- Prefer small, surgical edits; preserve both sides intent when possible.
- Re-run `git status`; ensure no conflict markers remain; run tests or lint the user cares about for touched areas.
### 5. If the user wants to abort
- `git merge --abort` or `git rebase --abort` as appropriate; confirm they lose in-progress integration state for that operation.
## Anti-patterns (do not do)
- Picking `--ours` / `--theirs` on the whole repo without user approval.
- “Resolving” by deleting a feature branchs work because main touched the same file.
- Hiding conflict lists inside a long code dump without a short executive summary.
- Fixing low-impact style conflicts first while leaving P0 decisions implicit.
## Quick reference
- **Ours vs theirs (merge):** stage 2 = current branch (`HEAD`), stage 3 = incoming (`MERGE_HEAD`). Verify with `git checkout --conflict=merge <file>` if needed.
- **Typical high-touch paths** in full-stack or Flows apps: root app shell, top navigation, route modules, and shared `lib/` or `hooks/`.
For optional command snippets and a merge message template, see [reference.md](reference.md).
@@ -0,0 +1,36 @@
# Reference: git commands & merge stages
## Useful commands
```bash
git status
git diff --name-only --diff-filter=U
git diff path/to/file
git show :2:path/to/file # ours (HEAD) during merge
git show :3:path/to/file # theirs (MERGE_HEAD) during merge
git log --oneline -5 HEAD MERGE_HEAD
```
## Abort
```bash
git merge --abort
git rebase --abort
```
## After user approves resolution
```bash
git add <resolved-files>
git commit # completes merge; use message that states target branch
```
## Suggested merge commit message shape
```
Merge origin/main into <branch>
<one line: what was integrated>
Conflict resolutions: <brief list or "per user direction in chat">.
```
+162
View File
@@ -0,0 +1,162 @@
---
name: reveal-3d
description: "Integrates a local Cognite Reveal 3D CAD viewer bundle into Flows apps by copying app-local source code. Use when adding 3D viewer, 3D visualization, Reveal, CAD model, RevealProvider, RevealCanvas, Reveal3DResources, FDM 3D mapping, asset 3D model, model browser, or Cognite 3D content to a Flows application."
metadata:
argument-hint: "[FDM instance variable name or description, e.g. 'asset' or 'selectedEquipment']"
---
# Reveal 3D Viewer
Add a Cognite Reveal 3D viewer to a Flows app by copying the bundled source into the target app. Renders CAD models from CDF, with support for model browsing, direct model/revision IDs, or FDM-linked assets.
FDM instance to visualize: **$ARGUMENTS**
## Use This When
The user wants to embed an interactive Cognite Reveal viewer for CDF 3D/CAD content in a Flows app.
Do **not** use this skill for static diagrams, graph visualizations, or unrelated custom Three.js scenes.
## Prerequisites
- The app uses React + TypeScript and is wrapped in `@cognite/dune` auth (Flows auth).
- The app has a `QueryClientProvider` from `@tanstack/react-query`.
- The CDF project has 3D models, or the user has supplied direct model/revision IDs.
- For FDM-linked 3D, the instance must be linked through Core DM (`CogniteVisualizable.object3D` -> `CogniteCADNode`).
## Integration Workflow
Follow these steps in order. Adapt paths to the target app's conventions instead of inventing new ones.
1. **Inspect the target app.** Read `package.json`, `vite.config.ts`, `src/main.tsx`, and the app's folder/alias conventions.
2. **Install missing dependencies** with the app's package manager. See [Dependencies](#dependencies). Reuse existing pinned React, Flows, SDK, and React Query versions.
3. **Copy the bundle into the app.** Copy every file from `skills/reveal-3d/code/reveal/` into an app-local feature folder, typically:
```text
src/features/reveal-3d/
```
4. **Import from the local folder**, never from the skill directory or the old external package. With a typical `@/*` alias:
```tsx
import { CacheProvider, RevealKeepAlive, RevealProvider } from '@/features/reveal-3d';
```
5. **Configure Vite and `main.tsx`.** Read [vite-config.md](references/vite-config.md) and apply the process polyfill, manual `process`/`util`/`assert` aliases, `three` alias, dedupe settings, and `worker.format: 'es'`.
6. **Choose the implementation pattern.** Use Pattern B (model browser or direct model ID) unless you already have a `DMInstanceRef` and confirmed Core DM 3D linkage. For full examples, read [implementation.md](references/implementation.md).
7. **Keep provider placement stable.** `CacheProvider` and `RevealKeepAlive` are always mounted at page/app level. `RevealProvider` is conditional, only when a model is selected or linked.
8. **Run typecheck and build** (`tsc --noEmit`, `pnpm build`, etc.) and fix any copied-import or dependency issues.
## Minimal Example
```tsx
import { useCallback, useMemo } from 'react';
import type { CogniteClient } from '@cognite/sdk';
import {
CacheProvider,
Reveal3DResources,
RevealCanvas,
RevealKeepAlive,
RevealProvider,
type AddCadResourceOptions,
} from '@/features/reveal-3d';
type SelectedModel = { modelId: number; revisionId: number };
function ViewerContent({ modelId, revisionId }: SelectedModel) {
const resources = useMemo<AddCadResourceOptions[]>(
() => [{ modelId, revisionId }],
[modelId, revisionId]
);
const onLoaded = useCallback(() => {}, []);
return (
<RevealCanvas>
<Reveal3DResources resources={resources} onModelsLoaded={onLoaded} />
</RevealCanvas>
);
}
export function ViewerPage({
sdk,
selected,
}: {
sdk: CogniteClient;
selected: SelectedModel | null;
}) {
const memoizedSdk = useMemo(() => sdk, [sdk.project]);
return (
<CacheProvider>
<RevealKeepAlive>
<div style={{ width: '100%', height: '70vh', position: 'relative' }}>
{selected && (
<RevealProvider sdk={memoizedSdk}>
<ViewerContent
modelId={selected.modelId}
revisionId={selected.revisionId}
/>
</RevealProvider>
)}
</div>
</RevealKeepAlive>
</CacheProvider>
);
}
```
## Dependencies
Suggested versions are starting points. If the target app already pins compatible versions, defer to the app.
| Package | Suggested version | Purpose |
|---------|-------------------|---------|
| `react` / `react-dom` | app version | UI framework |
| `@cognite/dune` | app version | Authenticated SDK via `useDune()` |
| `@cognite/reveal` | `^4.30.0` | Reveal viewer runtime |
| `@cognite/sdk` | `^10.0.0` | CDF API client |
| `@tanstack/react-query` | `^5.90.21` | Reveal/FDM data fetching hooks |
| `three` | `^0.180.0` | Three.js singleton used by Reveal |
| `process`, `util`, `assert` | latest | Browser polyfills for Reveal dependencies |
| `ajv` | `^8` | Avoids older transitive AJV resolution in monorepos |
| `@types/three` | latest dev dep | TypeScript types |
Example install (pnpm; adapt to the app's package manager):
```bash
pnpm add @cognite/reveal @cognite/sdk @tanstack/react-query three process util assert ajv
pnpm add -D @types/three
```
After install, check `@cognite/reveal`'s `three` peer requirement and align `three` if needed.
Do **not** install `vite-plugin-node-polyfills`; use the explicit Vite aliases in [vite-config.md](references/vite-config.md).
## Critical Rules
- `ViewerContent` contains only `RevealCanvas` and `Reveal3DResources`; no providers.
- `resources` passed to `Reveal3DResources` must be memoized with `useMemo`.
- `onModelsLoaded`, `onSelect`, and similar callbacks must be memoized with `useCallback`.
- The SDK passed to `RevealProvider` must be memoized with `useMemo` keyed on `client.project`.
- `RevealCanvas` fills its parent; the parent must have an explicit height.
- Lazy-load canvas-heavy viewer content with `React.lazy` + `Suspense` when adding a route/page.
## Advanced Reference
For the copied bundle API and exports, read `code/README.md`.
For model browser and FDM-linked implementations, read `references/implementation.md`.
For Vite, worker, polyfill, and troubleshooting details, read `references/vite-config.md`.
## Verification Checklist
- [ ] All files from `skills/reveal-3d/code/reveal/` were copied into an app-local feature folder.
- [ ] Imports point to the app-local folder (e.g. `@/features/reveal-3d`).
- [ ] The app does not import Reveal helpers from the old external package.
- [ ] Required dependencies are present in `package.json`.
- [ ] `main.tsx` starts with the `process` polyfill before other imports.
- [ ] `vite.config.ts` uses manual aliases, dedupe, `three` singleton alias, and `worker.format: 'es'`.
- [ ] `CacheProvider` and `RevealKeepAlive` are always mounted; `RevealProvider` is conditional when model selection is conditional.
- [ ] The viewer container has an explicit height.
- [ ] Typecheck and build pass.
+46
View File
@@ -0,0 +1,46 @@
# Reveal 3D Code Bundle
This bundle is copied from the Reveal source tree.
Copy the contents of `code/reveal/` into an app-local feature folder, typically:
```text
src/features/reveal-3d/
```
Then import from the app-local folder:
```tsx
import {
CacheProvider,
Reveal3DResources,
RevealCanvas,
RevealKeepAlive,
RevealProvider,
} from '@/features/reveal-3d';
```
Do not import from `skills/reveal-3d/code/reveal` in the target app.
## Dependencies
The copied code expects these app dependencies:
- `@cognite/reveal`
- `@cognite/sdk`
- `@tanstack/react-query`
- `react`
- `react-dom`
- `three`
The Vite setup for Reveal also needs `process`, `util`, `assert`, and `ajv`.
Install `@types/three` as a dev dependency for TypeScript apps.
## Public Exports
The public API is exported from `index.ts`, including:
- Components: `RevealProvider`, `RevealCanvas`, `Reveal3DResources`, `RevealKeepAlive`
- Providers/hooks: `CacheProvider`, `useReveal`, `useModelsForInstanceQuery`, `useFdmAssetMappings`
- Types: `AddCadResourceOptions`, `TaggedAddResourceOptions`, `ViewerOptions`
@@ -0,0 +1,180 @@
import type { Node3D } from '@cognite/sdk';
import type { DMInstanceRef } from '@cognite/reveal';
import { chunk, executeParallel } from '../utils/executeParallel';
const ASSET_MAPPING_CHUNK_SIZE = 1000;
/**
* Multi-level asset mapping cache with split-chunk strategy.
*
* Three-way indexing:
* - By model+revision: Fast lookup for all mappings in a model
* - By asset instance (space:externalId): Fast lookup for all nodes belonging to an asset
* - By node ID: Fast lookup for individual node metadata
*/
export class AssetMappingCache {
private byModelCache = new Map<string, Map<string, Node3D[]>>();
private byAssetCache = new Map<string, Node3D[]>();
private byNodeCache = new Map<string, Node3D>();
async getOrFetch(
modelId: number,
revisionId: number,
assetInstances: DMInstanceRef[],
fetchFn: (
modelId: number,
revisionId: number,
instances: DMInstanceRef[]
) => Promise<Map<string, Node3D[]>>
): Promise<Map<string, Node3D[]>> {
const modelKey = this.createModelKey(modelId, revisionId);
const cachedModel = this.byModelCache.get(modelKey);
if (cachedModel) {
const { cached, uncached } = this.splitCachedAndMissing(
assetInstances,
cachedModel
);
if (uncached.length === 0) {
return cached;
}
const fetched = await this.fetchAndCache(
modelId,
revisionId,
uncached,
fetchFn
);
return this.mergeMappings(cached, fetched);
}
return this.fetchAndCache(modelId, revisionId, assetInstances, fetchFn);
}
getCachedAssetMapping(instance: DMInstanceRef): Node3D[] | undefined {
return this.byAssetCache.get(this.createAssetKey(instance));
}
getCachedNode(modelId: number, revisionId: number, treeIndex: number): Node3D | undefined {
const key = this.createNodeKey(modelId, revisionId, treeIndex);
return this.byNodeCache.get(key);
}
clear(): void {
this.byModelCache.clear();
this.byAssetCache.clear();
this.byNodeCache.clear();
}
clearModel(modelId: number, revisionId: number): void {
const modelKey = this.createModelKey(modelId, revisionId);
this.byModelCache.delete(modelKey);
}
private createAssetKey(instance: DMInstanceRef): string {
return `${instance.space}:${instance.externalId}`;
}
private createModelKey(modelId: number, revisionId: number): string {
return `${modelId}/${revisionId}`;
}
private createNodeKey(modelId: number, revisionId: number, treeIndex: number): string {
return `${modelId}/${revisionId}/${treeIndex}`;
}
private splitCachedAndMissing(
assetInstances: DMInstanceRef[],
cachedModel: Map<string, Node3D[]>
): {
cached: Map<string, Node3D[]>;
uncached: DMInstanceRef[];
} {
const cached = new Map<string, Node3D[]>();
const uncached: DMInstanceRef[] = [];
for (const instance of assetInstances) {
const assetKey = this.createAssetKey(instance);
const cachedNodes = cachedModel.get(assetKey);
if (cachedNodes) {
cached.set(assetKey, cachedNodes);
} else {
uncached.push(instance);
}
}
return { cached, uncached };
}
private async fetchAndCache(
modelId: number,
revisionId: number,
assetInstances: DMInstanceRef[],
fetchFn: (
modelId: number,
revisionId: number,
instances: DMInstanceRef[]
) => Promise<Map<string, Node3D[]>>
): Promise<Map<string, Node3D[]>> {
const chunks = chunk(assetInstances, ASSET_MAPPING_CHUNK_SIZE);
const results = await executeParallel(
chunks.map((chunkInstances) => async () => {
return fetchFn(modelId, revisionId, chunkInstances);
}),
3
);
const merged = new Map<string, Node3D[]>();
for (const result of results) {
if (result) {
for (const [assetKey, nodes] of result.entries()) {
merged.set(assetKey, nodes);
}
}
}
this.indexMappings(modelId, revisionId, merged);
return merged;
}
private indexMappings(
modelId: number,
revisionId: number,
mappings: Map<string, Node3D[]>
): void {
const modelKey = this.createModelKey(modelId, revisionId);
let modelCache = this.byModelCache.get(modelKey);
if (!modelCache) {
modelCache = new Map();
this.byModelCache.set(modelKey, modelCache);
}
for (const [assetKey, nodes] of mappings.entries()) {
modelCache.set(assetKey, nodes);
this.byAssetCache.set(assetKey, nodes);
for (const node of nodes) {
if (node.treeIndex !== undefined) {
const nodeKey = this.createNodeKey(modelId, revisionId, node.treeIndex);
this.byNodeCache.set(nodeKey, node);
}
}
}
}
private mergeMappings(
map1: Map<string, Node3D[]>,
map2: Map<string, Node3D[]>
): Map<string, Node3D[]> {
const merged = new Map(map1);
for (const [key, value] of map2.entries()) {
merged.set(key, value);
}
return merged;
}
}
@@ -0,0 +1,41 @@
import { createContext, useContext, useMemo } from 'react';
import { AssetMappingCache } from './AssetMappingCache';
interface CacheContextValue {
assetMappingCache: AssetMappingCache;
}
const CacheContext = createContext<CacheContextValue | null>(null);
export function useCacheContext() {
const context = useContext(CacheContext);
if (!context) {
throw new Error('useCacheContext must be used within CacheProvider');
}
return context;
}
export function useOptionalCacheContext() {
return useContext(CacheContext);
}
interface CacheProviderProps {
children: React.ReactNode;
}
/**
* Provides shared cache instances to the component tree.
* Wrap your app or 3D viewer area with this to enable cross-navigation caching
* (70-90% reduction in API calls on subsequent visits).
*/
export function CacheProvider({ children }: CacheProviderProps) {
const cacheValue = useMemo(() => ({
assetMappingCache: new AssetMappingCache(),
}), []);
return (
<CacheContext.Provider value={cacheValue}>
{children}
</CacheContext.Provider>
);
}
@@ -0,0 +1,213 @@
import { useEffect, useRef } from 'react';
import {
type CogniteCadModel,
TreeIndexNodeCollection,
NumericRange,
} from '@cognite/reveal';
import { useReveal } from '../hooks/useReveal';
import type { AddCadResourceOptions, InstanceStylingGroup } from '../types';
import { useFdmAssetMappings } from '../hooks/useFdmMappings';
interface Reveal3DResourcesProps {
resources: AddCadResourceOptions[];
instanceStyling?: InstanceStylingGroup[];
onModelsLoaded?: () => void;
}
export function Reveal3DResources({
resources,
instanceStyling = [],
onModelsLoaded,
}: Reveal3DResourcesProps) {
const viewer = useReveal();
const loadedModelsRef = useRef<CogniteCadModel[]>([]);
useEffect(() => {
if (!viewer || resources.length === 0) {
return;
}
const cancelledRef = { current: false };
const loadModels = async () => {
const modelPromises = resources.map(async (resource) => {
if (cancelledRef.current) return null;
try {
const existing = viewer.models.find(
(m) =>
m.modelId === resource.modelId &&
m.revisionId === resource.revisionId
);
if (existing) {
return existing as CogniteCadModel;
}
const addModelOptions: {
modelId: number;
revisionId: number;
geometryFilter?: typeof resource.geometryFilter;
} = {
modelId: resource.modelId,
revisionId: resource.revisionId,
};
if (resource.geometryFilter) {
addModelOptions.geometryFilter = resource.geometryFilter;
}
const model = await viewer.addCadModel(addModelOptions);
if (cancelledRef.current) {
viewer.removeModel(model);
return null;
}
if (resource.styling?.default) {
const { renderGhosted, renderInFront } = resource.styling.default;
if (renderGhosted !== undefined) {
model.setDefaultNodeAppearance({
renderGhosted,
renderInFront: renderInFront ?? false,
});
}
}
return model;
} catch (error) {
console.error('Error loading CAD model:', error);
return null;
}
});
const loadedModels = (await Promise.all(modelPromises)).filter(
(model): model is CogniteCadModel => model !== null
);
if (!cancelledRef.current) {
loadedModelsRef.current = loadedModels;
if (loadedModels.length > 0) {
viewer.fitCameraToModels(loadedModels, 0);
}
if (onModelsLoaded) {
onModelsLoaded();
}
}
};
loadModels();
return () => {
cancelledRef.current = true;
const modelsToRemove = loadedModelsRef.current;
for (const model of modelsToRemove) {
try {
viewer.removeModel(model);
} catch (error) {
console.error('Error removing model:', error);
}
}
loadedModelsRef.current = [];
};
}, [viewer, resources, onModelsLoaded]);
useApplyInstanceStyling(viewer, loadedModelsRef.current, instanceStyling);
return null;
}
function useApplyInstanceStyling(
viewer: ReturnType<typeof useReveal>,
loadedModels: CogniteCadModel[],
instanceStyling: InstanceStylingGroup[]
) {
const appliedCollectionsRef = useRef<
Map<CogniteCadModel, TreeIndexNodeCollection[]>
>(new Map());
const fdmInstances =
instanceStyling.flatMap((group) => group.fdmAssetExternalIds || []) || [];
const modelOptions = loadedModels.map((model) => ({
type: 'cad' as const,
modelId: model.modelId,
revisionId: model.revisionId,
}));
const { data: assetMappings } = useFdmAssetMappings(
fdmInstances,
modelOptions
);
useEffect(() => {
if (!viewer || loadedModels.length === 0) return;
const hasFdmInstances = instanceStyling.some(
(g) => g.fdmAssetExternalIds && g.fdmAssetExternalIds.length > 0
);
if (!hasFdmInstances) {
for (const [model, collections] of appliedCollectionsRef.current.entries()) {
for (const collection of collections) {
try { model.unassignStyledNodeCollection(collection); } catch { /* already removed */ }
}
}
appliedCollectionsRef.current.clear();
return;
}
if (!assetMappings) return;
for (const [model, collections] of appliedCollectionsRef.current.entries()) {
for (const collection of collections) {
try { model.unassignStyledNodeCollection(collection); } catch { /* already removed */ }
}
}
appliedCollectionsRef.current.clear();
for (const stylingGroup of instanceStyling) {
if (!stylingGroup.fdmAssetExternalIds || !stylingGroup.style.cad) {
continue;
}
const appearance = stylingGroup.style.cad;
for (const model of loadedModels) {
const modelMapping = assetMappings.find(
(m) =>
m.modelId === model.modelId && m.revisionId === model.revisionId
);
if (!modelMapping) continue;
const nodeCollection = new TreeIndexNodeCollection();
const indexSet = nodeCollection.getIndexSet();
for (const instance of stylingGroup.fdmAssetExternalIds) {
const nodes = modelMapping.mappings.get(`${instance.space}:${instance.externalId}`);
if (!nodes) continue;
for (const node of nodes) {
if (
node.treeIndex !== undefined &&
node.subtreeSize !== undefined
) {
const range = new NumericRange(node.treeIndex, node.subtreeSize);
indexSet.addRange(range);
}
}
}
if (indexSet.count > 0) {
nodeCollection.updateSet(indexSet);
model.assignStyledNodeCollection(nodeCollection, appearance);
const existing = appliedCollectionsRef.current.get(model) ?? [];
existing.push(nodeCollection);
appliedCollectionsRef.current.set(model, existing);
}
}
}
}, [viewer, loadedModels, instanceStyling, assetMappings]);
}
@@ -0,0 +1,27 @@
import { type ReactNode, type ReactElement, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useRevealContext } from '../hooks/useRevealContext';
export function RevealCanvas({
children,
}: {
children?: ReactNode;
}): ReactElement {
const { viewer } = useRevealContext();
const parentElement = useRef<HTMLDivElement>(null);
useEffect(() => {
if (parentElement.current !== null) {
parentElement.current.appendChild(viewer.domElement);
}
}, [viewer]);
return (
<div
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
ref={parentElement}
>
{createPortal(children, viewer.domElement)}
</div>
);
}
@@ -0,0 +1,84 @@
import { useRef, useEffect, createContext, useContext, useCallback } from 'react';
import type { Cognite3DViewer } from '@cognite/reveal';
import type { CogniteClient } from '@cognite/sdk';
interface RevealKeepAliveContextValue {
getOrCreateViewer: (
sdk: CogniteClient,
createViewer: () => Cognite3DViewer
) => Cognite3DViewer;
isMounted: () => boolean;
}
const RevealKeepAliveContext = createContext<RevealKeepAliveContextValue | null>(
null
);
export function useRevealKeepAlive() {
const context = useContext(RevealKeepAliveContext);
if (!context) {
throw new Error('useRevealKeepAlive must be used within RevealKeepAlive');
}
return context;
}
/**
* Returns null when not inside a RevealKeepAlive provider.
* Used by RevealProvider to conditionally reuse a kept-alive viewer.
*/
export function useOptionalRevealKeepAlive(): RevealKeepAliveContextValue | null {
return useContext(RevealKeepAliveContext);
}
interface RevealKeepAliveProps {
children: React.ReactNode;
}
/**
* Keeps the Cognite3DViewer instance alive across component unmounts,
* eliminating viewer reinitialization when navigating between assets (~2-3s saving).
*
* The viewer persists in a ref that survives child unmount/remount cycles.
* Models are managed separately (added/removed as needed).
* Disposal is deferred to survive React StrictMode's mountunmountremount cycle.
*/
export function RevealKeepAlive({ children }: RevealKeepAliveProps) {
const viewerRef = useRef<Cognite3DViewer | null>(null);
const mountedRef = useRef(false);
const getOrCreateViewer = useCallback(
(_sdk: CogniteClient, createViewer: () => Cognite3DViewer) => {
if (!viewerRef.current) {
viewerRef.current = createViewer();
}
return viewerRef.current;
},
[]
);
const isMounted = useCallback(() => {
return mountedRef.current;
}, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
return () => {
if (viewerRef.current) {
viewerRef.current.dispose();
viewerRef.current = null;
}
};
}, []);
return (
<RevealKeepAliveContext.Provider value={{ getOrCreateViewer, isMounted }}>
{children}
</RevealKeepAliveContext.Provider>
);
}
@@ -0,0 +1,83 @@
import { useRef, useCallback, type ReactNode, useMemo } from 'react';
import type { InstanceStylingGroup } from '../types';
import {
InstanceStylingContext,
type InstanceStylingController,
} from './instanceStylingContext';
interface InstanceStylingProviderProps {
children: ReactNode;
}
/**
* Provider for centralized instance styling management.
* Components can register/unregister styling groups and listen for changes.
*/
export function InstanceStylingProvider({
children,
}: InstanceStylingProviderProps) {
const stylingGroupsRef = useRef<Map<string, InstanceStylingGroup>>(new Map());
const listenersRef = useRef<Set<() => void>>(new Set());
const nextIdRef = useRef(0);
const getStylingGroups = useCallback(() => {
return Array.from(stylingGroupsRef.current.values());
}, []);
const addEventListener = useCallback((callback: () => void): void => {
listenersRef.current.add(callback);
// Call once immediately to initialize with current state
callback();
}, []);
const removeEventListener = useCallback((callback: () => void): void => {
listenersRef.current.delete(callback);
}, []);
const notifyListeners = useCallback(() => {
listenersRef.current.forEach((listener) => listener());
}, []);
const registerStylingGroup = useCallback(
(group: InstanceStylingGroup): string => {
const id = `styling-group-${nextIdRef.current++}`;
stylingGroupsRef.current.set(id, group);
notifyListeners();
return id;
},
[notifyListeners]
);
const unregisterStylingGroup = useCallback(
(id: string): void => {
const deleted = stylingGroupsRef.current.delete(id);
if (deleted) {
notifyListeners();
}
},
[notifyListeners]
);
const controller: InstanceStylingController = useMemo(
() => ({
getStylingGroups,
addEventListener,
removeEventListener,
registerStylingGroup,
unregisterStylingGroup,
}),
[
getStylingGroups,
addEventListener,
removeEventListener,
registerStylingGroup,
unregisterStylingGroup,
]
);
return (
<InstanceStylingContext.Provider value={controller}>
{children}
</InstanceStylingContext.Provider>
);
}
@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react';
import { Cognite3DViewer } from '@cognite/reveal';
import type { RevealContextProps } from '../types';
import { RevealContext } from './revealContext';
import { InstanceStylingProvider } from './InstanceStylingProvider';
import { useOptionalRevealKeepAlive } from '../components/RevealKeepAlive';
import { RevealSettingsController } from '../settings/RevealSettingsController';
export function RevealProvider({
children,
sdk,
color,
viewerOptions,
}: RevealContextProps) {
const keepAlive = useOptionalRevealKeepAlive();
const keepAliveRef = useRef(keepAlive);
keepAliveRef.current = keepAlive;
const [viewerData] = useState(() => {
const createViewer = () =>
new Cognite3DViewer({
sdk,
useFlexibleCameraManager: true,
...viewerOptions,
});
const viewer = keepAlive
? keepAlive.getOrCreateViewer(sdk, createViewer)
: createViewer();
if (color) {
viewer.setBackgroundColor({ color, alpha: 1 });
}
return { viewer, sdk };
});
useEffect(() => {
const controller = new RevealSettingsController('medium');
controller.applyToViewer(viewerData.viewer);
return () => controller.dispose();
}, [viewerData.viewer]);
useEffect(() => {
if (color) {
viewerData.viewer.setBackgroundColor({ color, alpha: 1 });
}
}, [color, viewerData.viewer]);
useEffect(() => {
return () => {
if (!keepAliveRef.current) {
viewerData.viewer.dispose();
}
};
}, []);
return (
<RevealContext.Provider value={viewerData}>
<InstanceStylingProvider>{children}</InstanceStylingProvider>
</RevealContext.Provider>
);
}
@@ -0,0 +1,14 @@
import { createContext } from 'react';
import type { InstanceStylingGroup } from '../types';
export interface InstanceStylingController {
getStylingGroups: () => InstanceStylingGroup[];
addEventListener: (callback: () => void) => void;
removeEventListener: (callback: () => void) => void;
registerStylingGroup: (group: InstanceStylingGroup) => string;
unregisterStylingGroup: (id: string) => void;
}
export const InstanceStylingContext = createContext<
InstanceStylingController | undefined
>(undefined);
@@ -0,0 +1,12 @@
import { createContext } from 'react';
import type { Cognite3DViewer } from '@cognite/reveal';
import type { CogniteClient } from '@cognite/sdk';
export interface RevealContextValue {
viewer: Cognite3DViewer;
sdk: CogniteClient;
}
export const RevealContext = createContext<RevealContextValue | undefined>(
undefined
);
@@ -0,0 +1,30 @@
import type { DMInstanceRef } from '@cognite/reveal';
import { useMemo } from 'react';
import {
useInstancesWithBoundingBoxes,
type InstancesWithBoxesAndOriginalInstance,
} from './useInstancesWithBoundingBoxes';
import { useFindRelated3dInstances } from './useFindRelated3dInstances';
export const useInstancesWithBounds = (
inputInstances: DMInstanceRef[],
originalInstance: DMInstanceRef
): InstancesWithBoxesAndOriginalInstance | undefined => {
const instancesWithBounds = useInstancesWithBoundingBoxes(inputInstances);
return useMemo<InstancesWithBoxesAndOriginalInstance | undefined>(() => {
if (inputInstances.length === 0 || instancesWithBounds.length === 0) {
return undefined;
}
return { instancesWithBoxes: [...instancesWithBounds], originalInstance };
}, [instancesWithBounds, inputInstances.length, originalInstance]);
};
export function use3dDataForSelectedInstance(
instance: DMInstanceRef
): InstancesWithBoxesAndOriginalInstance | undefined {
const threeDRelatedSelection = useFindRelated3dInstances(instance);
const selectedInstancesWithBoundsAndCorrespondingInstance =
useInstancesWithBounds(threeDRelatedSelection, instance);
return selectedInstancesWithBoundsAndCorrespondingInstance;
}
@@ -0,0 +1,330 @@
import { useQuery } from '@tanstack/react-query';
import type { DMInstanceRef } from '@cognite/reveal';
import type { Node3D, CogniteClient } from '@cognite/sdk';
import { useRevealContext } from './useRevealContext';
import type { ThreeDModelFdmMappings, CadModelOptions } from '../types';
import {
ASSET_VIEW,
COGNITE_3D_OBJECT_VIEW,
COGNITE_CAD_NODE_VIEW,
} from '../utils/views';
import { unwrapProperties } from '../utils/data-mapper';
import type { CDFNode } from '../utils/cdf-types';
interface DmsUniqueIdentifier {
space: string;
externalId: string;
}
interface CogniteAssetProperties {
space: string;
externalId: string;
object3D: DmsUniqueIdentifier;
}
interface CogniteCADNodeProperties {
space: string;
externalId: string;
object3D: DmsUniqueIdentifier;
model3D: DmsUniqueIdentifier;
revisions: DmsUniqueIdentifier[];
treeIndexes: number[];
subTreeSizes: number[];
}
/**
* Fetches FDM-to-CAD mappings using Core DM connections.
* This queries the data model for CAD nodes connected to assets via object3D references.
*/
export function useFdmAssetMappings(
instances: DMInstanceRef[],
models: CadModelOptions[]
) {
const { sdk } = useRevealContext();
return useQuery({
queryKey: [
'fdm-cad-connections',
instances.map((i) => `${i.space}:${i.externalId}`).join(','),
models.map((m) => `${m.modelId}:${m.revisionId}`).join(','),
],
queryFn: async (): Promise<ThreeDModelFdmMappings[]> => {
if (instances.length === 0 || models.length === 0) {
return [];
}
try {
// Step 1: Query DMS for CAD connections
// This traverses: Assets → object3D → CAD nodes
const queryResult = await sdk.instances.query({
with: {
// Start from the input instances (assets)
assets: {
nodes: {
filter: {
and: [
{
in: {
property: ['node', 'space'],
values: [
...new Set(instances.map((inst) => inst.space)),
],
},
},
{
in: {
property: ['node', 'externalId'],
values: instances.map((inst) => inst.externalId),
},
},
],
},
},
},
// Navigate to object3D (Cognite3DObject)
object_3ds: {
nodes: {
from: 'assets',
through: {
view: { type: 'view', ...ASSET_VIEW },
identifier: 'object3D',
},
direction: 'outwards',
filter: {
hasData: [{ type: 'view', ...COGNITE_3D_OBJECT_VIEW }],
},
},
},
// Navigate back to CAD nodes that reference this object3D
cad_nodes: {
nodes: {
from: 'object_3ds',
through: {
view: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
identifier: 'object3D',
},
direction: 'inwards',
},
},
},
select: {
assets: {
sources: [
{
source: { type: 'view', ...ASSET_VIEW },
properties: ['object3D'],
},
],
},
cad_nodes: {
sources: [
{
source: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
properties: [
'object3D',
'model3D',
'revisions',
'treeIndexes',
'subTreeSizes',
],
},
],
},
},
});
// Step 2: Build mappings per model/revision
const mappingsByModel = new Map<string, Map<string, Node3D[]>>();
const cadNodes = queryResult.items.cad_nodes || [];
// Group CAD nodes by which instances reference them
const object3DToAssets = new Map<string, DMInstanceRef[]>();
for (const asset of queryResult.items.assets || []) {
const props = unwrapProperties<CogniteAssetProperties>(
asset as CDFNode,
ASSET_VIEW
);
if (props?.object3D) {
const key = `${props.object3D.space}/${props.object3D.externalId}`;
const existing = object3DToAssets.get(key) || [];
existing.push({ space: asset.space, externalId: asset.externalId });
object3DToAssets.set(key, existing);
}
}
// Collect all (modelId, revisionId, treeIndex) tuples for batch fetching
interface NodeRequest {
modelId: number;
revisionId: number;
treeIndex: number;
assetInstances: DMInstanceRef[];
}
const nodeRequests: NodeRequest[] = [];
// Process CAD nodes to build Node3D mappings
for (const cadNode of cadNodes) {
const props = unwrapProperties<CogniteCADNodeProperties>(
cadNode as CDFNode,
COGNITE_CAD_NODE_VIEW
);
if (!props) continue;
const { model3D, revisions, treeIndexes, object3D } = props;
if (!model3D || !revisions || !treeIndexes) continue;
// Find which assets reference this CAD node
const object3DKey = `${object3D.space}/${object3D.externalId}`;
const relatedAssets = object3DToAssets.get(object3DKey);
if (!relatedAssets) continue;
// Extract modelId and match with requested models
const modelId = extractModelId(model3D.externalId);
// For each revision/treeIndex pair
for (let i = 0; i < revisions.length; i++) {
const revision = revisions[i];
const treeIndex = treeIndexes[i];
const revisionId = extractRevisionId(revision.externalId);
// Check if this model/revision is in our requested list
const matchingModel = models.find(
(m) => m.modelId === modelId && m.revisionId === revisionId
);
if (!matchingModel) continue;
nodeRequests.push({
modelId,
revisionId,
treeIndex,
assetInstances: relatedAssets,
});
}
}
// Batch fetch nodes by revision
const nodesByRevision = new Map<string, NodeRequest[]>();
for (const req of nodeRequests) {
const key = `${req.modelId}/${req.revisionId}`;
const existing = nodesByRevision.get(key) || [];
existing.push(req);
nodesByRevision.set(key, existing);
}
// Fetch all nodes in parallel per revision
const revisionFetchPromises = Array.from(nodesByRevision.entries()).map(
async ([revisionKey, requests]) => {
const [modelId, revisionId] = revisionKey.split('/').map(Number);
const treeIndexes = requests.map((r) => r.treeIndex);
const nodes = await fetchNodesByTreeIndex(
sdk,
modelId,
revisionId,
treeIndexes
);
return { revisionKey, nodes, requests };
}
);
const allRevisionData = await Promise.all(revisionFetchPromises);
for (const { revisionKey, nodes, requests } of allRevisionData) {
const treeIndexToNode = new Map(
nodes.map((node) => [node.treeIndex, node])
);
const modelMappings =
mappingsByModel.get(revisionKey) ?? new Map<string, Node3D[]>();
mappingsByModel.set(revisionKey, modelMappings);
for (const req of requests) {
const node3D = treeIndexToNode.get(req.treeIndex);
if (!node3D) continue;
for (const instance of req.assetInstances) {
const instanceKey = `${instance.space}:${instance.externalId}`;
const arr = modelMappings.get(instanceKey) ?? [];
arr.push(node3D);
modelMappings.set(instanceKey, arr);
}
}
}
// Convert to result format
const results: ThreeDModelFdmMappings[] = [];
for (const model of models) {
const modelKey = `${model.modelId}/${model.revisionId}`;
results.push({
modelId: model.modelId,
revisionId: model.revisionId,
mappings: mappingsByModel.get(modelKey) ?? new Map(),
});
}
return results;
} catch (error) {
console.error('Error fetching FDM CAD connections:', error);
return [];
}
},
enabled: !!sdk && instances.length > 0 && models.length > 0,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Helper to extract numeric modelId from externalId like "model_123_space"
function extractModelId(externalId: string): number {
const match = externalId.match(/model_(\d+)/);
return match ? parseInt(match[1], 10) : -1;
}
// Helper to extract numeric revisionId from externalId like "model_123_revision_456_space"
function extractRevisionId(externalId: string): number {
const match = externalId.match(/revision_(\d+)/);
return match ? parseInt(match[1], 10) : -1;
}
/**
* Fetch 3D nodes by their tree indices using the optimized internal IDs endpoint.
* This is much more efficient than fetching all nodes and filtering.
*/
async function fetchNodesByTreeIndex(
sdk: CogniteClient,
modelId: number,
revisionId: number,
treeIndexes: number[]
): Promise<Node3D[]> {
if (treeIndexes.length === 0) {
return [];
}
// Deduplicate tree indices
const uniqueTreeIndexes = Array.from(new Set(treeIndexes));
// Step 1: Convert tree indices to internal node IDs
const nodeIdResponse = await sdk.post<{ items: number[] }>(
`/api/v1/projects/${sdk.project}/3d/models/${modelId}/revisions/${revisionId}/nodes/internalids/bytreeindices`,
{
data: {
items: uniqueTreeIndexes,
},
}
);
const nodeIds = nodeIdResponse.data.items;
if (nodeIds.length === 0) {
return [];
}
// Step 2: Retrieve full node details by internal IDs
const nodes = await sdk.revisions3D.retrieve3DNodes(
modelId,
revisionId,
nodeIds.map((id) => ({ id }))
);
return nodes;
}
@@ -0,0 +1,21 @@
import { useMemo } from 'react';
import {
use3dRelatedDirectConnections,
use3dRelatedEdgeConnections,
} from './useRelatedInstances';
import type { DMInstanceRef } from '@cognite/reveal';
export const useFindRelated3dInstances = (
instance: DMInstanceRef
): DMInstanceRef[] => {
const edgeRelationData = use3dRelatedEdgeConnections(instance);
const directRelationData = use3dRelatedDirectConnections(instance);
return useMemo<DMInstanceRef[]>(() => {
const edgeDirectRelationData = [
...(edgeRelationData.data ?? []),
...(directRelationData.data ?? []),
];
return [instance, ...edgeDirectRelationData];
}, [instance, edgeRelationData.data, directRelationData.data]);
};
@@ -0,0 +1,79 @@
import { DefaultCameraManager, type DMInstanceRef } from '@cognite/reveal';
import { useReveal } from './useReveal';
import { useCallback, useEffect } from 'react';
import { Box3, Vector3 } from 'three';
import { use3dDataForSelectedInstance } from './use3dDataForSelectedInstance';
import type { InstanceWithBoundingBox } from './useInstancesWithBoundingBoxes';
/**
* Calculate an angled camera position for a bounding box
* @param box - The bounding box to frame
* @returns Camera position and target vectors
*/
function calculateAngledCameraPosition(box: Box3): {
position: Vector3;
target: Vector3;
} {
// Get bounding box center and size
const center = new Vector3();
box.getCenter(center);
const size = new Vector3();
box.getSize(size);
// Calculate the maximum dimension to determine camera distance
const maxDim = Math.max(size.x, size.y, size.z);
const fov = 60; // Field of view in degrees
const cameraDistance = (maxDim / (2 * Math.tan((fov * Math.PI) / 360))) * 1.5;
// Position camera at 45-degree angle (above and to the side)
// Using spherical coordinates: 45° elevation, 45° azimuth
const angle = Math.PI / 4; // 45 degrees
const cameraPosition = new Vector3(
center.x + cameraDistance * Math.cos(angle) * Math.cos(angle),
center.y + cameraDistance * Math.sin(angle),
center.z + cameraDistance * Math.cos(angle) * Math.sin(angle)
);
return {
position: cameraPosition,
target: center,
};
}
const useFocusCameraWithInstanceBox = (
instancesWithBoundingBox: InstanceWithBoundingBox[]
) => {
const viewer = useReveal();
useEffect(() => {
if (viewer.cameraManager instanceof DefaultCameraManager) {
viewer.cameraManager.setCameraControlsOptions({
mouseWheelAction: 'zoomToCursor',
});
}
}, [viewer.cameraManager]);
return useCallback(() => {
if (instancesWithBoundingBox.length === 0) {
return;
}
const box = instancesWithBoundingBox.reduce(
(unionBox, instance) => unionBox.union(instance.boundingBox),
new Box3()
);
if (!box.isEmpty()) {
const cameraState = calculateAngledCameraPosition(box);
viewer.cameraManager.setCameraState(cameraState);
}
}, [instancesWithBoundingBox, viewer]);
};
export const useFocusCamera = (instance: DMInstanceRef) => {
const selectedInstanceData = use3dDataForSelectedInstance(instance);
return useFocusCameraWithInstanceBox(
selectedInstanceData?.instancesWithBoxes ?? []
);
};
@@ -0,0 +1,64 @@
import { useMemo } from 'react';
import { useEffect, useState } from 'react';
import { useRenderTarget } from './useRenderTarget';
import type { FdmAssetStylingGroup, InstanceStylingGroup } from '../types';
import { DefaultNodeAppearance, type DMInstanceRef } from '@cognite/reveal';
import { use3dDataForSelectedInstance } from './use3dDataForSelectedInstance';
import type { InstanceWithBoundingBox } from './useInstancesWithBoundingBoxes';
const useCentralizedInstanceStyling = (): InstanceStylingGroup[] => {
const [instanceStylingGroups, setInstanceStylingGroups] = useState<
InstanceStylingGroup[]
>([]);
const instanceStylingController = useRenderTarget().instanceStylingController;
useEffect(() => {
const onStylingChange = () => {
setInstanceStylingGroups([
...instanceStylingController.getStylingGroups(),
]);
};
instanceStylingController.addEventListener(onStylingChange);
return () => {
instanceStylingController.removeEventListener(onStylingChange);
};
}, [instanceStylingController]);
return instanceStylingGroups;
};
const getInstanceStyling = (
instances: InstanceWithBoundingBox[]
): FdmAssetStylingGroup[] =>
instances.length === 0
? []
: [
{
fdmAssetExternalIds: instances.map(({ instance }) => instance),
style: {
cad: DefaultNodeAppearance.Highlighted,
pointcloud: DefaultNodeAppearance.Highlighted,
},
},
];
export const useInstanceStyling = (instance: DMInstanceRef) => {
const selectedInstancesAndOriginalInstance =
use3dDataForSelectedInstance(instance);
const centralizedInstanceStyling = useCentralizedInstanceStyling();
return useMemo(
() => [
...centralizedInstanceStyling,
...getInstanceStyling(
selectedInstancesAndOriginalInstance?.instancesWithBoxes ?? []
),
],
[
selectedInstancesAndOriginalInstance?.instancesWithBoxes,
centralizedInstanceStyling,
]
);
};
@@ -0,0 +1,130 @@
import type { DMInstanceRef } from '@cognite/reveal';
import { use3dModels } from './useModels';
import { useFdmAssetMappings } from './useFdmMappings';
import type { CadModelOptions, ThreeDModelFdmMappings, CogniteModel } from '../types';
import type { Node3D } from '@cognite/sdk';
import { useMemo } from 'react';
import { Box3 } from 'three';
export type InstanceWithBoundingBox = {
instance: DMInstanceRef;
boundingBox: Box3;
};
export type InstancesWithBoxesAndOriginalInstance = {
instancesWithBoxes: InstanceWithBoundingBox[];
originalInstance: DMInstanceRef;
};
export type NodesWithModelInfo = {
nodes: Node3D[];
instance: DMInstanceRef;
modelId: number;
revisionId: number;
};
const combineNodeBoundingBoxes = (nodes: Node3D[]): Box3 =>
nodes.reduce(
(currentBox, nextNode) =>
currentBox.union(
nextNode.boundingBox !== undefined
? new Box3().setFromArray([
...nextNode.boundingBox.min,
...nextNode.boundingBox.max,
])
: new Box3()
),
new Box3()
);
const getFdmDataWithBoundingBoxes = (
modelsWithRelevantNodes: NodesWithModelInfo[],
models: CogniteModel[]
): InstanceWithBoundingBox[] => {
const cdfCoordinateBoundingBoxes = modelsWithRelevantNodes.map(
(nodesWithModel) => combineNodeBoundingBoxes(nodesWithModel.nodes)
);
const selectedNodeCadModels = modelsWithRelevantNodes.map((nodeModelData) =>
models.find(
({ modelId, revisionId }) =>
modelId === nodeModelData.modelId &&
revisionId === nodeModelData.revisionId
)
);
if (
selectedNodeCadModels.length === 0 ||
cdfCoordinateBoundingBoxes.length === 0
) {
return [];
}
const viewerCoordinateBoundingBoxes = selectedNodeCadModels
.map((model, ind) =>
model?.mapBoxFromCdfToModelCoordinates(cdfCoordinateBoundingBoxes[ind])
)
.filter((val) => val !== undefined);
return viewerCoordinateBoundingBoxes.map((boundingBox, ind) => ({
instance: modelsWithRelevantNodes[ind].instance!,
boundingBox,
}));
};
export function getNodesFromModelsFdmMappings(
instances: DMInstanceRef[],
mappings?: ThreeDModelFdmMappings[]
): NodesWithModelInfo[] {
const nodesWithModelIds = mappings?.flatMap((modelMappings) =>
instances.reduce((infoArray, instance) => {
const nodes = modelMappings.mappings.get(`${instance.space}:${instance.externalId}`);
if (nodes === undefined) {
return infoArray;
}
infoArray.push({
instance,
modelId: modelMappings.modelId,
revisionId: modelMappings.revisionId,
nodes,
});
return infoArray;
}, new Array<NodesWithModelInfo>())
);
return nodesWithModelIds ?? [];
}
const getBoundingBoxInstancesFromFdmAndModelMappings = (
instances: DMInstanceRef[],
modelMappings: ThreeDModelFdmMappings[] | undefined,
models: CogniteModel[]
): InstanceWithBoundingBox[] => {
const modelsWithRelevantNodes = getNodesFromModelsFdmMappings(
instances,
modelMappings
);
if (modelsWithRelevantNodes.length === 0) {
return [];
}
return getFdmDataWithBoundingBoxes(modelsWithRelevantNodes, models);
};
export const useInstancesWithBoundingBoxes = (
inputInstances: DMInstanceRef[]
) => {
const models = use3dModels();
const { data: modelNodeMappings } = useFdmAssetMappings(
inputInstances,
models as CadModelOptions[]
);
return useMemo(
() =>
getBoundingBoxInstancesFromFdmAndModelMappings(
inputInstances,
modelNodeMappings,
models
),
[modelNodeMappings, inputInstances, models]
);
};
@@ -0,0 +1,153 @@
import { useQuery } from '@tanstack/react-query';
import type { DMInstanceRef } from '@cognite/reveal';
import { useRevealContext } from './useRevealContext';
import type { TaggedAddResourceOptions } from '../types';
import { useReveal } from './useReveal';
import {
COGNITE_VISUALIZABLE_VIEW,
COGNITE_CAD_NODE_VIEW,
} from '../utils/views';
import { unwrapProperties } from '../utils/data-mapper';
import type { CDFNode } from '../utils/cdf-types';
interface CogniteCADNodeProperties {
space: string;
externalId: string;
model3D: { externalId: string; space: string };
revisions: Array<{ externalId: string; space: string }>;
}
/**
* Extracts numeric ID from CDF external ID format (e.g., "cog_3d_model_12345" -> 12345)
*/
function extractNumericId(externalId: string): number | undefined {
const lastUnderscoreIndex = externalId.lastIndexOf('_');
if (lastUnderscoreIndex === -1) return undefined;
const numericPart = externalId.substring(lastUnderscoreIndex + 1);
const id = parseInt(numericPart, 10);
return isNaN(id) ? undefined : id;
}
/**
* Fetches 3D CAD models associated with an FDM instance via the Cognite Core Data Model.
* Traverses: Asset -> object3D (CogniteVisualizable) -> CogniteCADNode -> models/revisions
*/
export function useModelsForInstanceQuery(instance: DMInstanceRef) {
const { sdk } = useRevealContext();
const result = useQuery({
queryKey: ['3d-models-for-instance', instance.space, instance.externalId],
queryFn: async () => {
try {
const response = await sdk.instances.query({
with: {
asset: {
nodes: {
filter: {
and: [
{
equals: {
property: ['node', 'externalId'],
value: instance.externalId,
},
},
{
equals: {
property: ['node', 'space'],
value: instance.space,
},
},
],
},
},
},
object_3ds: {
nodes: {
from: 'asset',
through: {
view: { type: 'view', ...COGNITE_VISUALIZABLE_VIEW },
identifier: 'object3D',
},
direction: 'outwards',
},
},
cad_nodes: {
nodes: {
from: 'object_3ds',
through: {
view: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
identifier: 'object3D',
},
direction: 'inwards',
},
},
},
select: {
cad_nodes: {
sources: [
{
source: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
properties: ['model3D', 'revisions'],
},
],
},
},
});
const models: TaggedAddResourceOptions[] = [];
const seenModels = new Set<string>();
// Extract model/revision info from CAD nodes
const cadNodes = response.items?.cad_nodes || [];
for (const node of cadNodes) {
const props = unwrapProperties<CogniteCADNodeProperties>(
node as CDFNode,
COGNITE_CAD_NODE_VIEW
);
if (!props?.model3D || !Array.isArray(props.revisions)) {
continue;
}
const modelId = extractNumericId(props.model3D.externalId);
if (!modelId) continue;
// Process each revision
for (const revision of props.revisions) {
const revisionId = extractNumericId(revision.externalId);
if (!revisionId) continue;
const modelKey = `${modelId}:${revisionId}`;
if (seenModels.has(modelKey)) continue;
seenModels.add(modelKey);
models.push({ type: 'cad', addOptions: { modelId, revisionId } });
}
}
return models;
} catch (error) {
console.error('[useModelsForInstanceQuery] Error:', error);
return [] as TaggedAddResourceOptions[];
}
},
enabled: !!sdk,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
});
return result;
}
/**
* Returns all loaded 3D models in the viewer.
* Models are only present after Reveal3DResources has loaded them.
*
* Note: This returns the viewer.models array directly without polling.
* Components should be structured so that model loading triggers re-renders naturally.
*/
export function use3dModels() {
const viewer = useReveal();
return viewer.models || [];
}
@@ -0,0 +1,331 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { DMInstanceRef } from '@cognite/reveal';
import type { Node3D, CogniteClient } from '@cognite/sdk';
import { useRevealContext } from './useRevealContext';
import type { ThreeDModelFdmMappings } from '../types';
import {
ASSET_VIEW,
COGNITE_3D_OBJECT_VIEW,
COGNITE_CAD_NODE_VIEW,
} from '../utils/views';
import { unwrapProperties } from '../utils/data-mapper';
import type { CDFNode } from '../utils/cdf-types';
import { executeParallel, chunk } from '../utils/executeParallel';
const TREE_INDEX_CHUNK_SIZE = 1000;
const NODE_ID_CHUNK_SIZE = 100;
interface ModelRef {
modelId: number;
revisionId: number;
}
interface DmsUniqueIdentifier {
space: string;
externalId: string;
}
interface CogniteAssetProperties {
space: string;
externalId: string;
object3D: DmsUniqueIdentifier;
}
interface CogniteCADNodeProperties {
space: string;
externalId: string;
object3D: DmsUniqueIdentifier;
model3D: DmsUniqueIdentifier;
revisions: DmsUniqueIdentifier[];
treeIndexes: number[];
subTreeSizes: number[];
}
/**
* Prefetch FDM asset mappings using model IDs from the query result,
* NOT waiting for models to be loaded into the viewer.
*
* This eliminates 1-3 seconds from the critical path by running mapping fetch
* in parallel with model loading instead of sequentially after.
*/
export function usePrefetchedFdmMappings(
instances: DMInstanceRef[],
modelRefs: ModelRef[]
) {
const { sdk } = useRevealContext();
const instancesKey = useMemo(
() => instances.map((i) => `${i.space}:${i.externalId}`).join(','),
[instances]
);
const modelRefsKey = useMemo(
() => modelRefs.map((m) => `${m.modelId}:${m.revisionId}`).join(','),
[modelRefs]
);
return useQuery({
queryKey: ['fdm-cad-connections-prefetched', instancesKey, modelRefsKey],
queryFn: async (): Promise<ThreeDModelFdmMappings[]> => {
if (instances.length === 0 || modelRefs.length === 0) {
return [];
}
try {
const queryResult = await sdk.instances.query({
with: {
assets: {
nodes: {
filter: {
and: [
{
in: {
property: ['node', 'space'],
values: [
...new Set(instances.map((inst) => inst.space)),
],
},
},
{
in: {
property: ['node', 'externalId'],
values: instances.map((inst) => inst.externalId),
},
},
],
},
},
},
object_3ds: {
nodes: {
from: 'assets',
through: {
view: { type: 'view', ...ASSET_VIEW },
identifier: 'object3D',
},
direction: 'outwards',
filter: {
hasData: [{ type: 'view', ...COGNITE_3D_OBJECT_VIEW }],
},
},
},
cad_nodes: {
nodes: {
from: 'object_3ds',
through: {
view: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
identifier: 'object3D',
},
direction: 'inwards',
},
},
},
select: {
assets: {
sources: [
{
source: { type: 'view', ...ASSET_VIEW },
properties: ['object3D'],
},
],
},
cad_nodes: {
sources: [
{
source: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
properties: [
'object3D',
'model3D',
'revisions',
'treeIndexes',
'subTreeSizes',
],
},
],
},
},
});
const mappingsByModel = new Map<string, Map<string, Node3D[]>>();
const cadNodes = queryResult.items.cad_nodes || [];
const object3DToAssets = new Map<string, DMInstanceRef[]>();
for (const asset of queryResult.items.assets || []) {
const props = unwrapProperties<CogniteAssetProperties>(
asset as CDFNode,
ASSET_VIEW
);
if (props?.object3D) {
const key = `${props.object3D.space}/${props.object3D.externalId}`;
const existing = object3DToAssets.get(key) || [];
existing.push({ space: asset.space, externalId: asset.externalId });
object3DToAssets.set(key, existing);
}
}
interface NodeRequest {
modelId: number;
revisionId: number;
treeIndex: number;
assetInstances: DMInstanceRef[];
}
const nodeRequests: NodeRequest[] = [];
for (const cadNode of cadNodes) {
const props = unwrapProperties<CogniteCADNodeProperties>(
cadNode as CDFNode,
COGNITE_CAD_NODE_VIEW
);
if (!props) continue;
const { model3D, revisions, treeIndexes, object3D } = props;
if (!model3D || !revisions || !treeIndexes) continue;
const object3DKey = `${object3D.space}/${object3D.externalId}`;
const relatedAssets = object3DToAssets.get(object3DKey);
if (!relatedAssets) continue;
const modelId = extractModelId(model3D.externalId);
for (let i = 0; i < revisions.length; i++) {
const revision = revisions[i];
const treeIndex = treeIndexes[i];
const revisionId = extractRevisionId(revision.externalId);
const matchingModel = modelRefs.find(
(m) => m.modelId === modelId && m.revisionId === revisionId
);
if (!matchingModel) continue;
nodeRequests.push({
modelId,
revisionId,
treeIndex,
assetInstances: relatedAssets,
});
}
}
const nodesByRevision = new Map<string, NodeRequest[]>();
for (const req of nodeRequests) {
const key = `${req.modelId}/${req.revisionId}`;
const existing = nodesByRevision.get(key) || [];
existing.push(req);
nodesByRevision.set(key, existing);
}
const revisionFetchPromises = Array.from(nodesByRevision.entries()).map(
async ([revisionKey, requests]) => {
const [modelId, revisionId] = revisionKey.split('/').map(Number);
const treeIndexes = requests.map((r) => r.treeIndex);
const nodes = await fetchNodesByTreeIndex(
sdk,
modelId,
revisionId,
treeIndexes
);
return { revisionKey, nodes, requests };
}
);
const allRevisionData = await Promise.all(revisionFetchPromises);
for (const { revisionKey, nodes, requests } of allRevisionData) {
const treeIndexToNode = new Map(
nodes.map((node) => [node.treeIndex, node])
);
const modelMappings =
mappingsByModel.get(revisionKey) ?? new Map<string, Node3D[]>();
mappingsByModel.set(revisionKey, modelMappings);
for (const req of requests) {
const node3D = treeIndexToNode.get(req.treeIndex);
if (!node3D) continue;
for (const instance of req.assetInstances) {
const instanceKey = `${instance.space}:${instance.externalId}`;
const arr = modelMappings.get(instanceKey) ?? [];
arr.push(node3D);
modelMappings.set(instanceKey, arr);
}
}
}
const results: ThreeDModelFdmMappings[] = [];
for (const modelRef of modelRefs) {
const modelKey = `${modelRef.modelId}/${modelRef.revisionId}`;
results.push({
modelId: modelRef.modelId,
revisionId: modelRef.revisionId,
mappings: mappingsByModel.get(modelKey) ?? new Map(),
});
}
return results;
} catch (error) {
console.error('Error prefetching FDM CAD connections:', error);
return [];
}
},
enabled: !!sdk && instances.length > 0 && modelRefs.length > 0,
staleTime: 5 * 60 * 1000,
});
}
function extractModelId(externalId: string): number {
const match = externalId.match(/model_(\d+)/);
return match ? parseInt(match[1], 10) : -1;
}
function extractRevisionId(externalId: string): number {
const match = externalId.match(/revision_(\d+)/);
return match ? parseInt(match[1], 10) : -1;
}
async function fetchNodesByTreeIndex(
sdk: CogniteClient,
modelId: number,
revisionId: number,
treeIndexes: number[]
): Promise<Node3D[]> {
if (treeIndexes.length === 0) return [];
const uniqueTreeIndexes = Array.from(new Set(treeIndexes));
const treeIndexChunks = chunk(uniqueTreeIndexes, TREE_INDEX_CHUNK_SIZE);
const results = await executeParallel(
treeIndexChunks.map((indexChunk) => async () => {
const nodeIdResponse = await sdk.post<{ items: number[] }>(
`/api/v1/projects/${sdk.project}/3d/models/${modelId}/revisions/${revisionId}/nodes/internalids/bytreeindices`,
{
data: { items: indexChunk },
}
);
const nodeIds = nodeIdResponse.data.items;
if (nodeIds.length === 0) return [];
const nodeIdChunks = chunk(nodeIds, NODE_ID_CHUNK_SIZE);
const nodeResults = await executeParallel(
nodeIdChunks.map((idChunk) => async () => {
const nodes = await sdk.revisions3D.retrieve3DNodes(
modelId,
revisionId,
idChunk.map((id) => ({ id }))
);
return Array.isArray(nodes) ? nodes : [];
}),
3
);
return nodeResults.flat().filter((node): node is Node3D => node !== undefined);
}),
3
);
return results.flat().filter((node): node is Node3D => node !== undefined);
}
@@ -0,0 +1,250 @@
import { useQuery } from '@tanstack/react-query';
import type { DMInstanceRef } from '@cognite/reveal';
import type { ViewDefinition, ViewReference } from '@cognite/sdk';
import { useRevealContext } from './useRevealContext';
import {
COGNITE_VISUALIZABLE_VIEW,
COGNITE_3D_OBJECT_VIEW,
} from '../utils/views';
type DmsUniqueIdentifier = {
space: string;
externalId: string;
};
export function use3dRelatedEdgeConnections(instance: DMInstanceRef) {
const { sdk } = useRevealContext();
return useQuery({
queryKey: ['3d-related-edges', instance.space, instance.externalId],
queryFn: async (): Promise<DMInstanceRef[]> => {
try {
// Query for nodes connected via edges that have 3D data
const response = await sdk.instances.query({
with: {
start_instance: {
nodes: {
filter: {
and: [
{
equals: {
property: ['node', 'externalId'],
value: instance.externalId,
},
},
{
equals: {
property: ['node', 'space'],
value: instance.space,
},
},
],
},
},
limit: 1,
},
start_to_object_edges: {
edges: {
from: 'start_instance',
maxDistance: 1,
direction: 'outwards',
},
limit: 1000,
},
objects_connected_with_3d: {
nodes: {
from: 'start_to_object_edges',
chainTo: 'destination',
filter: {
exists: {
property: [
COGNITE_VISUALIZABLE_VIEW.space,
COGNITE_VISUALIZABLE_VIEW.externalId,
'object3D',
],
},
},
},
},
object_3ds: {
nodes: {
from: 'objects_connected_with_3d',
through: {
view: { type: 'view', ...COGNITE_VISUALIZABLE_VIEW },
identifier: 'object3D',
},
},
limit: 1000,
},
},
select: {
start_instance: {},
start_to_object_edges: {},
objects_connected_with_3d: {},
object_3ds: {},
},
});
// Return the instances that are connected to 3D objects
return (
response.items?.objects_connected_with_3d?.map((node) => ({
space: node.space,
externalId: node.externalId,
})) ?? []
);
} catch (error) {
console.error('Error fetching related edge connections:', error);
return [];
}
},
enabled: !!sdk,
});
}
export function use3dRelatedDirectConnections(instance: DMInstanceRef) {
const { sdk } = useRevealContext();
return useQuery({
queryKey: ['3d-related-direct', instance.space, instance.externalId],
queryFn: async (): Promise<DMInstanceRef[]> => {
try {
// Step 1: Inspect the instance to find its views
const views = await sdk.instances.inspect({
inspectionOperations: { involvedViews: {} },
items: [
{
instanceType: 'node',
externalId: instance.externalId,
space: instance.space,
},
],
});
const view = views.items[0]?.inspectionResults?.involvedViews?.[0];
// Step 2: Get the instance content with its views
const instanceResponse = await sdk.instances.retrieve({
items: [
{
instanceType: 'node',
externalId: instance.externalId,
space: instance.space,
},
],
sources: view ? [{ source: view }] : undefined,
});
const instanceContent = instanceResponse.items[0];
if (!instanceContent?.properties) {
return [];
}
// Step 3: Extract all direct relation properties
const directlyRelatedObjects = Object.values(
instanceContent.properties
).flatMap((spaceScope) => {
if (typeof spaceScope !== 'object' || !spaceScope) return [];
return Object.values(spaceScope).flatMap((fieldValues) => {
if (typeof fieldValues !== 'object' || !fieldValues) return [];
return Object.values(fieldValues).filter(
(value): value is DmsUniqueIdentifier =>
typeof value === 'object' &&
'externalId' in value &&
'space' in value &&
typeof value.externalId === 'string' &&
typeof value.space === 'string'
);
});
});
if (directlyRelatedObjects.length === 0) {
return [];
}
// Step 4: Inspect all related objects to get their views
const relatedObjectInspectionsResult = await sdk.instances.inspect({
inspectionOperations: { involvedViews: {} },
items: directlyRelatedObjects.map((fdmId) => ({
...fdmId,
instanceType: 'node',
})),
});
const relatedObjectsViewLists =
relatedObjectInspectionsResult.items.map(
(item) => item.inspectionResults?.involvedViews ?? []
);
// Step 5: Create a mapping of object index to views
const relatedObjectViewsWithObjectIndex = relatedObjectsViewLists
.map((viewList, idx) => viewList.map((view) => [idx, view] as const))
.flat();
// Step 6: Deduplicate views and fetch their definitions
const [deduplicatedViews, viewToDeduplicatedIndexMap] =
createDeduplicatedViewToIndexMap(relatedObjectViewsWithObjectIndex);
const viewProps = await sdk.views.retrieve(
deduplicatedViews.map((view) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { type, ...viewWithoutType } = view;
return viewWithoutType;
}),
{ includeInheritedProperties: true }
);
// Step 7: Filter to only 3D-related views
const threeDRelatedViews = relatedObjectViewsWithObjectIndex.filter(
([, view]) => {
const viewResultIndex = viewToDeduplicatedIndexMap.get(
createViewKey(view)
);
if (viewResultIndex === undefined) return false;
const propsForView = viewProps.items[viewResultIndex];
return is3dView(propsForView);
}
);
// Step 8: Return the 3D-related instances
return threeDRelatedViews.map(([idx]) => directlyRelatedObjects[idx]);
} catch (error) {
console.error('Error fetching related direct connections:', error);
return [];
}
},
enabled: !!sdk,
});
}
// Helper functions
type ViewKey = `${string}/${string}/${string}`;
function createViewKey(source: ViewReference): ViewKey {
return `${source.externalId}/${source.space}/${source.version}`;
}
function createDeduplicatedViewToIndexMap(
viewsWithObjectIndex: Array<readonly [number, ViewReference]>
): [Array<ViewReference>, Map<ViewKey, number>] {
const deduplicatedViews: Array<ViewReference> = [];
const viewToDeduplicatedIndexMap = new Map<ViewKey, number>();
viewsWithObjectIndex.forEach(([, view]) => {
const viewKey = createViewKey(view);
if (!viewToDeduplicatedIndexMap.has(viewKey)) {
viewToDeduplicatedIndexMap.set(viewKey, deduplicatedViews.length);
deduplicatedViews.push(view);
}
});
return [deduplicatedViews, viewToDeduplicatedIndexMap];
}
/**
* Check if a view is 3D-related by checking if it implements Cognite3DObject
*/
function is3dView(view: ViewDefinition): boolean {
return (view.implements ?? []).some(
(type) =>
type.externalId === COGNITE_3D_OBJECT_VIEW.externalId &&
type.space === COGNITE_3D_OBJECT_VIEW.space &&
type.version === COGNITE_3D_OBJECT_VIEW.version
);
}
@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react';
import type { Cognite3DViewer } from '@cognite/reveal';
/**
* Automatically removes models from the viewer that are no longer referenced
* by the current component tree. Prevents memory accumulation when using
* RevealKeepAlive for viewer persistence (50-70% memory reduction on navigation).
*/
export function useRemoveNonReferencedModels(
viewer: Cognite3DViewer | null,
activeModelKeys: Set<string>
) {
const prevKeysRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!viewer) return;
const removed = [...prevKeysRef.current].filter((k) => !activeModelKeys.has(k));
for (const key of removed) {
const [modelIdStr, revisionIdStr] = key.split('-');
const modelId = parseInt(modelIdStr, 10);
const revisionId = parseInt(revisionIdStr, 10);
if (isNaN(modelId) || isNaN(revisionId)) continue;
const model = viewer.models.find(
(m) => m.modelId === modelId && m.revisionId === revisionId
);
if (model) {
try {
viewer.removeModel(model);
} catch (error) {
console.error(
`[useRemoveNonReferencedModels] Error removing model ${key}:`,
error
);
}
}
}
prevKeysRef.current = new Set(activeModelKeys);
}, [viewer, activeModelKeys]);
}
@@ -0,0 +1,30 @@
import { useContext } from 'react';
import {
InstanceStylingContext,
type InstanceStylingController,
} from '../context/instanceStylingContext';
export interface RenderTarget {
instanceStylingController: InstanceStylingController;
}
/**
* Hook to access the render target which includes the instance styling controller.
* This allows components to read and react to centralized styling state.
*
* The controller provides methods to:
* - registerStylingGroup(group): Register a new styling group and get its ID
* - unregisterStylingGroup(id): Remove a styling group by ID
* - getStylingGroups(): Get all current styling groups
* - addEventListener(callback): Subscribe to styling changes
* - removeEventListener(callback): Unsubscribe from styling changes
*/
export function useRenderTarget(): RenderTarget {
const stylingContext = useContext(InstanceStylingContext);
if (!stylingContext) {
throw new Error(
'useRenderTarget must be used within an InstanceStylingProvider'
);
}
return { instanceStylingController: stylingContext };
}
@@ -0,0 +1,11 @@
import type { Cognite3DViewer } from '@cognite/reveal';
import { useRevealContext } from './useRevealContext';
/**
* Hook to access the Reveal viewer instance.
* Must be used within a RevealProvider.
*/
export function useReveal(): Cognite3DViewer {
const { viewer } = useRevealContext();
return viewer;
}
@@ -0,0 +1,13 @@
import { useContext } from 'react';
import {
RevealContext,
type RevealContextValue,
} from '../context/revealContext';
export function useRevealContext(): RevealContextValue {
const context = useContext(RevealContext);
if (context === undefined) {
throw new Error('useRevealContext must be used within a RevealProvider');
}
return context;
}
@@ -0,0 +1,54 @@
// Context
export { RevealProvider } from './context/RevealProvider';
export { InstanceStylingProvider } from './context/InstanceStylingProvider';
export { useRevealContext } from './hooks/useRevealContext';
export type { InstanceStylingController } from './context/instanceStylingContext';
export { CacheProvider, useCacheContext, useOptionalCacheContext } from './cache/CacheProvider';
// Hooks
export { useReveal } from './hooks/useReveal';
export { useModelsForInstanceQuery, use3dModels } from './hooks/useModels';
export { useFdmAssetMappings } from './hooks/useFdmMappings';
export { usePrefetchedFdmMappings } from './hooks/usePrefetchedFdmMappings';
export {
use3dRelatedEdgeConnections,
use3dRelatedDirectConnections,
} from './hooks/useRelatedInstances';
export { useRenderTarget, type RenderTarget } from './hooks/useRenderTarget';
export {
use3dDataForSelectedInstance,
useInstancesWithBounds,
} from './hooks/use3dDataForSelectedInstance';
export { useFindRelated3dInstances } from './hooks/useFindRelated3dInstances';
export { useFocusCamera } from './hooks/useFocusCamera';
export { useInstanceStyling } from './hooks/useInstanceStyling';
export {
useInstancesWithBoundingBoxes,
getNodesFromModelsFdmMappings,
type InstanceWithBoundingBox,
type InstancesWithBoxesAndOriginalInstance,
type NodesWithModelInfo,
} from './hooks/useInstancesWithBoundingBoxes';
export { useRemoveNonReferencedModels } from './hooks/useRemoveNonReferencedModels';
// Components
export { RevealCanvas } from './components/RevealCanvas';
export { Reveal3DResources } from './components/Reveal3DResources';
export {
RevealKeepAlive,
useRevealKeepAlive,
useOptionalRevealKeepAlive,
} from './components/RevealKeepAlive';
// Types
export type {
ViewerOptions,
AddCadResourceOptions,
CadModelOptions,
TaggedAddResourceOptions,
ThreeDModelFdmMappings,
FdmAssetStylingGroup,
InstanceStylingGroup,
RevealContextProps,
CogniteModel,
} from './types';
@@ -0,0 +1,55 @@
export interface QualitySettings {
cadBudget: {
maximumRenderCost: number;
highDetailProximityThreshold: number;
};
pointCloudBudget: {
numberOfPoints: number;
};
resolutionOptions: {
maxRenderResolution: number;
movingCameraResolutionFactor: number;
};
}
export const QUALITY_PRESETS: Record<'low' | 'medium' | 'high', QualitySettings> = {
low: {
cadBudget: {
maximumRenderCost: 5_000_000,
highDetailProximityThreshold: 0,
},
pointCloudBudget: {
numberOfPoints: 1_000_000,
},
resolutionOptions: {
maxRenderResolution: 0.7e6,
movingCameraResolutionFactor: 0.3,
},
},
medium: {
cadBudget: {
maximumRenderCost: 15_000_000,
highDetailProximityThreshold: 0,
},
pointCloudBudget: {
numberOfPoints: 3_000_000,
},
resolutionOptions: {
maxRenderResolution: 1.4e6,
movingCameraResolutionFactor: 0.5,
},
},
high: {
cadBudget: {
maximumRenderCost: 45_000_000,
highDetailProximityThreshold: 10,
},
pointCloudBudget: {
numberOfPoints: 12_000_000,
},
resolutionOptions: {
maxRenderResolution: Infinity,
movingCameraResolutionFactor: 1.0,
},
},
};
@@ -0,0 +1,125 @@
import type { Cognite3DViewer } from '@cognite/reveal';
import { QUALITY_PRESETS, type QualitySettings } from './QualityPresets';
const CAMERA_IDLE_DEBOUNCE_MS = 200;
/**
* Centralized quality settings controller for the Reveal viewer.
*
* Manages CAD/point-cloud budgets, resolution caps, and dynamic resolution
* scaling during camera movement. All API calls are wrapped in try/catch
* so the controller never crashes viewer initialization if a method is
* unavailable on a particular Reveal version.
*/
export class RevealSettingsController {
private settings: QualitySettings;
private viewer: Cognite3DViewer | null = null;
private moveTimeout: ReturnType<typeof setTimeout> | null = null;
private cameraChangeHandler: (() => void) | null = null;
constructor(quality: 'low' | 'medium' | 'high' = 'medium') {
this.settings = QUALITY_PRESETS[quality];
}
applyToViewer(viewer: Cognite3DViewer): void {
this.removeEventListeners();
this.viewer = viewer;
try {
if ('cadBudget' in viewer) {
viewer.cadBudget = this.settings.cadBudget;
}
} catch (e) {
console.warn('[RevealSettingsController] Failed to set cadBudget:', e);
}
try {
if ('pointCloudBudget' in viewer) {
viewer.pointCloudBudget = this.settings.pointCloudBudget;
}
} catch (e) {
console.warn('[RevealSettingsController] Failed to set pointCloudBudget:', e);
}
try {
if (typeof viewer.setResolutionOptions === 'function') {
viewer.setResolutionOptions({
maxRenderResolution: this.settings.resolutionOptions.maxRenderResolution,
});
}
} catch (e) {
console.warn('[RevealSettingsController] Failed to set resolution:', e);
}
this.setupDynamicResolution(viewer);
}
setQuality(quality: 'low' | 'medium' | 'high'): void {
this.settings = QUALITY_PRESETS[quality];
if (this.viewer) {
this.applyToViewer(this.viewer);
}
}
getSettings(): QualitySettings {
return { ...this.settings };
}
dispose(): void {
if (this.moveTimeout) {
clearTimeout(this.moveTimeout);
this.moveTimeout = null;
}
this.removeEventListeners();
}
private removeEventListeners(): void {
if (this.viewer && this.cameraChangeHandler) {
try {
this.viewer.off('cameraChange', this.cameraChangeHandler);
} catch {
// Ignore if viewer is already disposed
}
this.cameraChangeHandler = null;
}
}
private setupDynamicResolution(viewer: Cognite3DViewer): void {
if (typeof viewer.setResolutionOptions !== 'function') {
return;
}
this.cameraChangeHandler = () => {
try {
viewer.setResolutionOptions({
maxRenderResolution:
this.settings.resolutionOptions.maxRenderResolution *
this.settings.resolutionOptions.movingCameraResolutionFactor,
});
if (this.moveTimeout) {
clearTimeout(this.moveTimeout);
}
this.moveTimeout = setTimeout(() => {
try {
viewer.setResolutionOptions({
maxRenderResolution: this.settings.resolutionOptions.maxRenderResolution,
});
} catch {
// Viewer may have been disposed
}
}, CAMERA_IDLE_DEBOUNCE_MS);
} catch {
// Viewer may have been disposed
}
};
try {
viewer.on('cameraChange', this.cameraChangeHandler);
} catch (e) {
console.warn('[RevealSettingsController] Failed to add cameraChange listener:', e);
this.cameraChangeHandler = null;
}
}
}
@@ -0,0 +1,90 @@
import type { CogniteClient, Node3D } from '@cognite/sdk';
import type {
CogniteCadModel,
CognitePointCloudModel,
DMInstanceRef,
NodeAppearance,
Cognite3DViewerOptions,
} from '@cognite/reveal';
import type * as THREE from 'three';
// Viewer Options - subset of Cognite3DViewerOptions
export type ViewerOptions = Pick<
Cognite3DViewerOptions,
'loadingIndicatorStyle' | 'antiAliasingHint' | 'ssaoQualityHint'
>;
// Geometry filter for partial model loading (matches Reveal SDK's GeometryFilter)
export interface RevealGeometryFilter {
boundingBox: THREE.Box3;
isBoundingBoxInModelCoordinates?: boolean;
}
// CAD Model Options
export interface AddCadResourceOptions {
modelId: number;
revisionId: number;
styling?: {
default?: {
renderGhosted?: boolean;
renderInFront?: boolean;
};
};
geometryFilter?: RevealGeometryFilter;
}
export interface CadModelOptions {
type: 'cad';
modelId: number;
revisionId: number;
}
// Tagged Resource Options
export type TaggedAddResourceOptions =
| {
type: 'cad';
addOptions: AddCadResourceOptions;
}
| {
type: 'pointcloud';
addOptions: {
modelId: number;
revisionId: number;
};
};
// FDM Mappings
export interface ThreeDModelFdmMappings {
modelId: number;
revisionId: number;
mappings: Map<string, Node3D[]>; // instanceKey (space:externalId) -> array of 3D nodes
}
// Styling
export interface FdmAssetStylingGroup {
fdmAssetExternalIds: DMInstanceRef[];
style: {
cad?: NodeAppearance;
pointcloud?: NodeAppearance;
};
}
export interface InstanceStylingGroup {
fdmAssetExternalIds?: DMInstanceRef[];
style: {
cad?: NodeAppearance;
pointcloud?: NodeAppearance;
};
}
// Reveal Context Props
export interface RevealContextProps {
children: React.ReactNode;
sdk: CogniteClient;
color?: THREE.Color;
viewerOptions?: ViewerOptions;
useCoreDm?: boolean;
}
// Model with type info
export type CogniteModel = CogniteCadModel | CognitePointCloudModel;
@@ -0,0 +1,24 @@
/**
* Core CDF Data Modeling Types
* These are the base types used across the application for working with CDF data models.
* They represent the raw structure of data returned from CDF APIs.
*/
/**
* Reference to a view in the data model
*/
export type ViewId = {
space: string;
externalId: string;
version: string;
};
/**
* Base interface for CDF nodes representing the raw structure from CDF APIs.
* CDF nests properties as: properties[space][view/version]
*/
export interface CDFNode {
space: string;
externalId: string;
properties: Record<string, unknown>;
}
@@ -0,0 +1,54 @@
/**
* Data Mapper Utilities
* Unwraps CDF's nested property structure to flat application types
*/
import type { CDFNode, ViewId } from './cdf-types';
/**
* Unwraps properties from a CDF node for a specific view.
* CDF nests properties as: properties[space][view/version]
* This function flattens them to: { space, externalId, ...properties }
*
* @example
* const batch = unwrapProperties<Batch>(cdfBatch, BATCH_VIEW);
* const material = unwrapProperties<Material>(cdfMaterial, MATERIAL_VIEW);
*/
export function unwrapProperties<
T extends { space: string; externalId: string },
>(node: CDFNode, view: ViewId): T {
const props: Record<string, unknown> = {};
if (node.properties) {
const spaceProps = node.properties[view.space] as
| Record<string, unknown>
| undefined;
if (spaceProps) {
const viewKey = `${view.externalId}/${view.version}`;
const viewProps = spaceProps[viewKey] as
| Record<string, unknown>
| undefined;
if (viewProps) {
Object.assign(props, viewProps);
}
}
}
return {
space: node.space,
externalId: node.externalId,
...props,
} as T;
}
/**
* Unwraps an array of CDF nodes
*
* @example
* const batches = unwrapPropertiesArray<Batch>(cdfBatches, BATCH_VIEW);
*/
export function unwrapPropertiesArray<
T extends { space: string; externalId: string },
>(nodes: CDFNode[], view: ViewId): T[] {
return nodes.map((node) => unwrapProperties<T>(node, view));
}
@@ -0,0 +1,42 @@
/**
* Execute async callbacks in parallel with a controlled concurrency limit.
* Prevents overwhelming the API while maximizing throughput.
*/
export async function executeParallel<T>(
callbacks: Array<() => Promise<T>>,
maxParallel: number
): Promise<Array<T | undefined>> {
const results: Array<T | undefined> = new Array(callbacks.length);
let nextIndex = 0;
async function runNext(): Promise<void> {
const currentIndex = nextIndex++;
if (currentIndex >= callbacks.length) return;
try {
results[currentIndex] = await callbacks[currentIndex]();
} catch (error) {
console.error(`executeParallel: callback ${currentIndex} failed:`, error);
results[currentIndex] = undefined;
}
await runNext();
}
await Promise.all(
Array.from({ length: Math.min(maxParallel, callbacks.length) }, () => runNext())
);
return results;
}
/**
* Split an array into smaller arrays of the given size.
*/
export function chunk<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
@@ -0,0 +1,70 @@
/**
* Core Data Model view constants
* These reference the standard Cognite Core Data Model views
*/
import { ViewId } from './cdf-types';
/**
* Core Data Model space constant
*/
export const CORE_DM_SPACE = 'cdf_cdm';
/**
* Core Data Model version
*/
export const CORE_DM_VERSION = 'v1';
/**
* View reference for CogniteAsset entities (from Core Data Model)
*/
export const ASSET_VIEW: ViewId = {
space: CORE_DM_SPACE,
externalId: 'CogniteAsset',
version: CORE_DM_VERSION,
};
/**
* View reference for Cognite3DObject entities (from Core Data Model)
*/
export const COGNITE_3D_OBJECT_VIEW: ViewId = {
space: CORE_DM_SPACE,
externalId: 'Cognite3DObject',
version: CORE_DM_VERSION,
};
/**
* View reference for CogniteCADNode entities (from Core Data Model)
*/
export const COGNITE_CAD_NODE_VIEW: ViewId = {
space: CORE_DM_SPACE,
externalId: 'CogniteCADNode',
version: CORE_DM_VERSION,
};
/**
* View reference for CogniteVisualizable entities (from Core Data Model)
*/
export const COGNITE_VISUALIZABLE_VIEW: ViewId = {
space: CORE_DM_SPACE,
externalId: 'CogniteVisualizable',
version: CORE_DM_VERSION,
};
/**
* View reference for Cognite3DModel entities (from Core Data Model)
*/
export const COGNITE_3D_MODEL_VIEW: ViewId = {
space: CORE_DM_SPACE,
externalId: 'Cognite3DModel',
version: CORE_DM_VERSION,
};
/**
* View reference for CogniteCADRevision entities (from Core Data Model)
*/
export const COGNITE_CAD_REVISION_VIEW: ViewId = {
space: CORE_DM_SPACE,
externalId: 'CogniteCADRevision',
version: CORE_DM_VERSION,
};
+201
View File
@@ -0,0 +1,201 @@
{
"skill_name": "reveal-3d",
"evals": [
{
"id": 1,
"prompt": "I have a Dune app at apps/my-app. Add a 3D viewer page where I can browse and load CAD models from my CDF project. Add a button on the main page to reach it.",
"expected_output": "A working 3D viewer page with the local Reveal feature bundle copied into the app, model browsing (sdk.models3D.list), Vite configured correctly for Reveal with manual util/assert/process package aliases (NOT vite-plugin-node-polyfills), process polyfill in main.tsx, DuneAuthProvider from @cognite/dune as auth provider, and all provider nesting correct.",
"files": [],
"assertions": [
{
"text": "@cognite/reveal, @cognite/sdk, @tanstack/react-query, three, process, util, assert, and ajv are in package.json dependencies",
"type": "content"
},
{
"text": "All files from skills/reveal-3d/code/reveal are copied into an app-local feature folder such as src/features/reveal-3d",
"type": "content"
},
{
"text": "Reveal components and hooks are imported from the app-local feature folder, e.g. @/features/reveal-3d",
"type": "content"
},
{
"text": "vite-plugin-node-polyfills is NOT present in package.json or vite.config.ts",
"type": "content"
},
{
"text": "vite.config.ts has manual resolve.alias entries for util ('util/'), assert ('assert/'), and process ('process/browser')",
"type": "content"
},
{
"text": "src/dune-fe-auth-shim.ts does NOT exist (no longer needed — the copied Reveal bundle reads sdk from RevealContext directly)",
"type": "content"
},
{
"text": "vite.config.ts resolve.alias does NOT contain a 'dune-fe-auth' entry",
"type": "content"
},
{
"text": "vite.config.ts has resolve.alias.three pointing to node_modules/three/build/three.module.js",
"type": "content"
},
{
"text": "vite.config.ts has worker: { format: 'es' }",
"type": "content"
},
{
"text": "vite.config.ts has resolve.dedupe including react, react-dom, and @tanstack/react-query",
"type": "content"
},
{
"text": "vite.config.ts has optimizeDeps.include with process, util, assert, three, @cognite/reveal, react, react-dom, and @tanstack/react-query",
"type": "content"
},
{
"text": "vite.config.ts does NOT exclude the copied src/features/reveal-3d bundle from optimizeDeps",
"type": "content"
},
{
"text": "main.tsx starts with import process from 'process'; window.process = process; before any other imports",
"type": "content"
},
{
"text": "main.tsx uses DuneAuthProvider from @cognite/dune as the auth provider (NOT CDFAuthenticationProvider)",
"type": "content"
},
{
"text": "CacheProvider and RevealKeepAlive are rendered at the page/app level, NOT inside a conditionally-rendered component",
"type": "content"
},
{
"text": "RevealProvider is rendered conditionally (only when a model is selected), inside the stable RevealKeepAlive tree",
"type": "content"
},
{
"text": "The canvas content component (RevealCanvas + Reveal3DResources) contains NO provider components",
"type": "content"
},
{
"text": "SDK passed to RevealProvider is memoized with useMemo keyed on sdk.project",
"type": "content"
},
{
"text": "resources prop for Reveal3DResources is memoized with useMemo",
"type": "content"
},
{
"text": "onModelsLoaded callback is memoized with useCallback",
"type": "content"
},
{
"text": "The onLoad prop passed to ModelBrowser is wrapped in useCallback (not an inline arrow function)",
"type": "content"
},
{
"text": "The viewer container div has an explicit height (e.g. height: '70vh' or h-[65vh])",
"type": "content"
},
{
"text": "The canvas content component is lazy-loaded with React.lazy and wrapped in Suspense",
"type": "content"
}
]
},
{
"id": 2,
"prompt": "I have an FDM asset with space cdf_idm and externalId pump_001. Add a 3D viewer to my asset detail page that shows the CAD model linked to this asset. The detail page is at src/AssetDetail.tsx.",
"expected_output": "A viewer component using the app-local Reveal feature bundle and useModelsForInstanceQuery to discover linked 3D models, integrated into AssetDetail.tsx with CacheProvider+RevealKeepAlive at the page level and RevealProvider conditionally rendered.",
"files": [],
"assertions": [
{
"text": "useModelsForInstanceQuery is imported from the app-local Reveal feature folder, e.g. @/features/reveal-3d",
"type": "content"
},
{
"text": "A DMInstanceRef with space 'cdf_idm' and externalId 'pump_001' is passed to useModelsForInstanceQuery",
"type": "content"
},
{
"text": "CacheProvider and RevealKeepAlive are at the AssetDetail page level (not inside the conditional viewer area)",
"type": "content"
},
{
"text": "RevealProvider is conditionally rendered (only when models data exists)",
"type": "content"
},
{
"text": "resources is derived from useModelsForInstanceQuery result and memoized with useMemo",
"type": "content"
},
{
"text": "onModelsLoaded is wrapped in useCallback",
"type": "content"
},
{
"text": "SDK is memoized with useMemo keyed on sdk.project",
"type": "content"
},
{
"text": "The viewer container has an explicit height",
"type": "content"
},
{
"text": "main.tsx uses DuneAuthProvider from @cognite/dune as the auth provider (NOT CDFAuthenticationProvider)",
"type": "content"
},
{
"text": "vite.config.ts uses manual util/assert/process aliases and does NOT use vite-plugin-node-polyfills",
"type": "content"
},
{
"text": "src/dune-fe-auth-shim.ts does NOT exist and vite.config.ts resolve.alias has no 'dune-fe-auth' entry",
"type": "content"
}
]
},
{
"id": 3,
"prompt": "Add a full-screen 3D viewer that loads model ID 206509079235820 revision 576781257263693. Put it at a /viewer route.",
"expected_output": "A full-screen viewer component using the app-local Reveal feature bundle with direct modelId/revisionId (Pattern B), accessible at /viewer, with correct provider nesting and Vite config.",
"files": [],
"assertions": [
{
"text": "Reveal3DResources receives a resources array containing modelId 206509079235820 and revisionId 576781257263693",
"type": "content"
},
{
"text": "The resources array is memoized with useMemo",
"type": "content"
},
{
"text": "onModelsLoaded is wrapped in useCallback",
"type": "content"
},
{
"text": "CacheProvider and RevealKeepAlive are at the viewer page level, not co-located with RevealProvider in the same conditional block",
"type": "content"
},
{
"text": "The viewer container is full-screen (height: 100vh or h-screen or equivalent)",
"type": "content"
},
{
"text": "A route is configured at /viewer",
"type": "content"
},
{
"text": "vite.config.ts has worker: { format: 'es' } and uses manual util/assert/process aliases (NOT vite-plugin-node-polyfills)",
"type": "content"
},
{
"text": "main.tsx uses DuneAuthProvider from @cognite/dune as the auth provider (NOT CDFAuthenticationProvider)",
"type": "content"
},
{
"text": "The canvas content is lazy-loaded with React.lazy and Suspense",
"type": "content"
}
]
}
]
}
@@ -0,0 +1,301 @@
# Implementation Reference — Reveal 3D Viewer
Full copy-paste ready implementations. Copy `skills/reveal-3d/code/reveal/` into an app-local feature folder first, typically `src/features/reveal-3d/`, then import from that local folder. **Pattern B (model browser — auto-loads models) is the default** — use it unless the data has explicit FDM → CAD node linkage.
---
## Pattern B (default) — model browser, auto-discovers models via `sdk.models3D.list()`
Canvas-only component — no providers. `CacheProvider`, `RevealKeepAlive`, and `RevealProvider` live in App.tsx.
```tsx
import { useCallback, useMemo } from 'react';
import {
Reveal3DResources,
RevealCanvas,
type AddCadResourceOptions,
} from '@/features/reveal-3d';
export interface ViewerContentProps {
modelId: number;
revisionId: number;
}
/**
* Canvas-only — no CacheProvider, RevealKeepAlive, or RevealProvider here.
* All providers live in App.tsx so React StrictMode double-invoke completes
* at startup before any model loading starts.
*/
export function ViewerContent({ modelId, revisionId }: ViewerContentProps) {
// AddCadResourceOptions is just { modelId, revisionId } — no `type` field.
// Do NOT use { type: 'cad', modelId, revisionId } — that is TaggedAddResourceOptions.
const resources = useMemo<AddCadResourceOptions[]>(
() => [{ modelId, revisionId }],
[modelId, revisionId]
);
const onLoaded = useCallback(() => {}, []);
return (
<RevealCanvas>
<Reveal3DResources resources={resources} onModelsLoaded={onLoaded} />
</RevealCanvas>
);
}
```
### src/App.tsx — providers + model browser
```tsx
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import * as THREE from 'three';
import { useDune } from '@cognite/dune';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import type { Model3D, Revision3D } from '@cognite/sdk';
import {
CacheProvider,
RevealKeepAlive,
RevealProvider,
type ViewerOptions,
} from '@/features/reveal-3d';
// Lazy-load canvas content — providers are eagerly imported
const ViewerContent = lazy(() =>
import('./components/ViewerContent').then((m) => ({ default: m.ViewerContent }))
);
// Module-level constants — stable references, never recreated on re-render
const BG = new THREE.Color(0x1a1a2e);
const VIEWER_OPTIONS: ViewerOptions = {
loadingIndicatorStyle: { placement: 'topRight', opacity: 0.1 },
antiAliasingHint: 'msaa2+fxaa',
ssaoQualityHint: 'medium',
};
type SelectedModel = { modelId: number; revisionId: number };
// --- Model discovery hooks ---
function useModels(query?: string) {
const { sdk } = useDune();
return useInfiniteQuery({
queryKey: ['3d-models', query],
queryFn: ({ pageParam }: { pageParam?: string }) =>
sdk.models3D.list({ limit: 1000, cursor: pageParam }) as Promise<{
items: Model3D[];
nextCursor?: string;
}>,
initialPageParam: undefined as string | undefined,
getNextPageParam: (page) => page.nextCursor,
enabled: !!sdk,
select: useCallback(
(data: any) => ({
...data,
pages: data.pages.map((p: any) => ({
...p,
items: p.items
.map((m: Model3D) => ({ ...m, name: m.name.trim() }))
.filter((m: Model3D) =>
query ? m.name.toLowerCase().includes(query.toLowerCase()) : true
),
})),
}),
[query]
),
});
}
function useBestRevision(modelId?: number) {
const { sdk } = useDune();
return useQuery({
queryKey: ['3d-revisions', modelId],
queryFn: async () => {
if (!modelId) return null;
const all: Revision3D[] = await sdk.revisions3D
.list(modelId)
.autoPagingToArray({ limit: -1 });
const published = all.filter((r) => r.published);
const candidates = published.length ? published : all;
return candidates.reduce((best, cur) =>
best.createdTime > cur.createdTime ? best : cur
) ?? null;
},
enabled: !!sdk && !!modelId,
});
}
// --- Model browser ---
//
// RULE 1: onSelect MUST be wrapped in useCallback at the call site.
// An inline arrow `(m) => setSelected(m)` creates a new reference every render.
// The useEffect([..., onSelect]) below fires on every render → infinite loop.
//
// RULE 2: call onSelect from useEffect, NOT during render.
// An if-block during render calling onSelect also causes infinite loops.
function ModelBrowser({ onSelect }: { onSelect: (m: SelectedModel) => void }) {
const [query, setQuery] = useState('');
const [pendingId, setPendingId] = useState<number>();
const { data } = useModels(query);
const { data: revision } = useBestRevision(pendingId);
const models = data?.pages.flatMap((p) => p.items) ?? [];
useEffect(() => {
if (revision && pendingId) {
onSelect({ modelId: pendingId, revisionId: revision.id });
}
}, [revision, pendingId, onSelect]);
return (
<div>
<input
placeholder="Search models…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{models.map((m) => (
<button key={m.id} onClick={() => setPendingId(m.id)}>
{m.name}
</button>
))}
</div>
);
}
// --- App ---
export default function App() {
const { sdk: client, isLoading } = useDune();
// Memoize on sdk.project — prevents RevealProvider from remounting on
// unrelated sdk object reference changes
const sdk = useMemo(() => client, [client.project]);
const [selected, setSelected] = useState<SelectedModel | null>(null);
// useCallback is mandatory — see ModelBrowser RULE 1 above
const handleSelect = useCallback((m: SelectedModel) => {
setSelected((prev) => (!prev || prev.modelId !== m.modelId ? m : prev));
}, []);
if (isLoading) return <div>Connecting to CDF…</div>;
return (
// CacheProvider + RevealKeepAlive always mounted → StrictMode double-invoke
// completes at startup with no viewer to dispose.
// RevealProvider conditionally mounts → finds stable RevealKeepAlive viewerRef.
<CacheProvider>
<RevealKeepAlive>
<div style={{ display: 'flex', height: '100vh' }}>
<aside style={{ width: 280, overflowY: 'auto' }}>
<ModelBrowser onSelect={handleSelect} />
</aside>
<div style={{ flex: 1, position: 'relative' }}>
{selected && (
<RevealProvider sdk={sdk} color={BG} viewerOptions={VIEWER_OPTIONS}>
<Suspense fallback={<div>Loading viewer…</div>}>
<ViewerContent
modelId={selected.modelId}
revisionId={selected.revisionId}
/>
</Suspense>
</RevealProvider>
)}
</div>
</div>
</RevealKeepAlive>
</CacheProvider>
);
}
```
---
## Pattern A (fallback) — FDM auto-discover from an asset instance
Use only when you have a `DMInstanceRef` and the instance has `CogniteVisualizable.object3D → CogniteCADNode` linkage. Otherwise use Pattern B above.
### src/components/ViewerContent.tsx (FDM variant)
```tsx
import { useCallback, useMemo, useState } from 'react';
import type { DMInstanceRef } from '@cognite/reveal';
import {
Reveal3DResources,
RevealCanvas,
useModelsForInstanceQuery,
type AddCadResourceOptions,
type TaggedAddResourceOptions,
} from '@/features/reveal-3d';
function pickFirstCad(models: TaggedAddResourceOptions[]): AddCadResourceOptions | undefined {
const m = models[0];
return m?.type === 'cad'
? { ...m.addOptions, styling: { default: { renderGhosted: true } } }
: undefined;
}
export function ViewerContent({ instance }: { instance: DMInstanceRef }) {
const { data: models, isLoading } = useModelsForInstanceQuery(instance);
const [loaded, setLoaded] = useState(false);
const selected = useMemo(() => pickFirstCad(models ?? []), [models]);
const resources = useMemo(() => (selected ? [selected] : []), [selected]);
const onLoaded = useCallback(() => setLoaded(true), []);
if (isLoading) return <div>Loading 3D model…</div>;
if (!resources.length) return <div>No 3D data linked to this instance.</div>;
return (
<RevealCanvas>
<Reveal3DResources resources={resources} onModelsLoaded={onLoaded} />
</RevealCanvas>
);
}
```
### src/App.tsx (FDM variant)
Same `CacheProvider` / `RevealKeepAlive` / `RevealProvider` structure as Pattern B.
Pass `instance: DMInstanceRef` to `ViewerContent` instead of `modelId` / `revisionId`.
```tsx
import { useMemo, useState } from 'react';
import * as THREE from 'three';
import { useDune } from '@cognite/dune';
import type { DMInstanceRef } from '@cognite/reveal';
import {
CacheProvider,
RevealKeepAlive,
RevealProvider,
type ViewerOptions,
} from '@/features/reveal-3d';
import { ViewerContent } from './components/ViewerContent';
const BG = new THREE.Color(0x1a1a2e);
const OPTS: ViewerOptions = {
loadingIndicatorStyle: { placement: 'topRight', opacity: 0.1 },
antiAliasingHint: 'msaa2+fxaa',
ssaoQualityHint: 'medium',
};
export default function App() {
const { sdk: client, isLoading } = useDune();
const sdk = useMemo(() => client, [client.project]);
// Replace with however you receive the instance ref (prop, route param, selection, etc.)
const [instance] = useState<DMInstanceRef | null>(null);
if (isLoading) return <div>Connecting to CDF…</div>;
return (
<CacheProvider>
<RevealKeepAlive>
<div style={{ width: '100%', height: '100vh', position: 'relative' }}>
{instance && sdk.project && (
<RevealProvider sdk={sdk} color={BG} viewerOptions={OPTS}>
<ViewerContent instance={instance} />
</RevealProvider>
)}
</div>
</RevealKeepAlive>
</CacheProvider>
);
}
```
@@ -0,0 +1,136 @@
# Vite Configuration for @cognite/reveal in a Flows monorepo app
## src/main.tsx — process polyfill must be the very first two lines
Prepend these two lines before any other import:
```tsx
import process from 'process';
(window as unknown as Record<string, unknown>).process = process;
// all other existing imports below ↓
```
The rest of `main.tsx` stays as-is. Order matters — the polyfill must run before any module that reads `process`.
---
## vite.config.ts — standalone config (not mergeConfig)
Flows apps use a standalone `vite.config.ts` (not a shared base config from the monorepo root).
Replace the file entirely with the following:
```typescript
import path from 'node:path';
import { fusionOpenPlugin } from '@cognite/dune/vite';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import mkcert from 'vite-plugin-mkcert';
export default defineConfig({
base: './',
plugins: [react(), mkcert(), fusionOpenPlugin(), tailwindcss()],
define: {
// Some CJS deps use `global` instead of `globalThis`
'process.env': {},
'process.platform': JSON.stringify(''),
'process.version': JSON.stringify(''),
global: 'globalThis',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
// --- Node built-in polyfills ---
// Use explicit package aliases, not vite-plugin-node-polyfills.
// The plugin introduces transitive dep conflicts ("Could not resolve 'inherits'").
// The `process`, `util`, and `assert` packages must be in app dependencies.
util: 'util/',
assert: 'assert/',
process: 'process/browser',
// --- Single Three.js instance ---
// @cognite/reveal bundles its own Three.js copy. Without this alias, the app
// and Reveal load two separate copies → "Multiple instances of Three.js" warning
// and broken rendering. Requires `three` as a direct app dependency.
three: path.resolve(__dirname, 'node_modules/three/build/three.module.js'),
},
// pnpm uses a virtual store with symlinks. Packages that symlink to different
// paths can resolve to separate module instances even for the same package.
// `dedupe` forces a single physical copy for all pre-bundles.
// Missing `react`/`react-dom` here causes ReactCurrentDispatcher errors.
// Missing `three` causes "Multiple instances" warnings.
dedupe: ['react', 'react-dom', 'react/jsx-runtime', '@tanstack/react-query', 'three'],
conditions: ['import', 'module', 'browser', 'default'],
},
optimizeDeps: {
// Do not exclude the copied Reveal feature bundle. Let Vite pre-bundle
// React, React Query, Three.js, and Reveal dependencies as one graph.
esbuildOptions: {
define: { global: 'globalThis' },
},
include: [
// Vite can't auto-discover bare polyfill imports (no source file imports them
// directly). List them explicitly so esbuild pre-bundles them.
'process',
'util',
'assert',
// Heavy/complex deps — explicit listing speeds up cold starts
'three',
'@cognite/reveal',
// React ecosystem — pre-bundling creates the CJS→ESM singleton all deps share.
// If any of these are missing, a dep that imports React raw can get a second copy.
'react',
'react-dom',
'@tanstack/react-query',
],
},
server: {
port: 3002,
},
worker: {
// @cognite/reveal spawns ES module web workers. Without 'es' format they fail
// silently — black screen with no console error.
format: 'es',
},
});
```
---
## Why each setting is needed
| Setting | Reason |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `util/`, `assert/`, `process/browser` aliases | Browser-compatible replacements for Node built-ins. Packages must be in `dependencies`. Do NOT use `vite-plugin-node-polyfills` — causes "Could not resolve 'inherits'" |
| `process` polyfill in main.tsx first | `@cognite/reveal` deps call `process.env` at **runtime** (not build-time). The `define` replacements handle build-time; the window assignment handles runtime |
| `define.global = 'globalThis'` | Some CJS deps use `global` instead of `globalThis` |
| `resolve.alias.three` | Single Three.js instance — without this, Reveal's bundled copy and the app's copy conflict |
| `resolve.dedupe` with react + react-dom + react/jsx-runtime | pnpm symlinks can create separate module instances. `dedupe` forces one copy. Critical — missing these causes `ReactCurrentDispatcher` errors |
| `resolve.dedupe` with three | Ensures the copied feature bundle and Reveal share the same Three.js |
| `optimizeDeps.include` for process/util/assert | No source file imports them, so Vite cannot auto-discover them for pre-bundling |
| `optimizeDeps.include` for react + react-dom + @tanstack/react-query | Converts CJS → ESM and creates a single shared instance. All pre-bundled deps that import React get the same copy |
| `worker.format: 'es'` | Reveal spawns ES module workers; Vite defaults to IIFE/UMD which breaks them |
| `conditions: ['import', 'module', 'browser', 'default']` | Ensures browser ESM variants are preferred over CJS/Node variants |
## Common mistakes that break the setup
| Mistake | Symptom | Fix |
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Installing `three` without checking `@cognite/reveal`'s peer requirement | `unmet peer three@0.180.0: found 0.177.x` warning; potential rendering bugs | After install, compare versions; `pnpm add three@^<peer-version>` if mismatched |
| Not adding `ajv` as a direct dependency | `unmet peer ajv@>=8: found 6.x` | `pnpm add ajv` (installs `^8`) in the app |
| Copied Reveal feature folder in `optimizeDeps.exclude` | `ReactCurrentDispatcher` undefined or `No QueryClient set` | Remove from `exclude` and let Vite pre-bundle shared deps |
| `vite-plugin-node-polyfills` instead of manual aliases | `Could not resolve "inherits"` on transitive deps | Remove the plugin; add `util`, `assert`, `process` to dependencies and use aliases |
| `RevealKeepAlive` inside conditional component | `ObjectUnsubscribedError: object unsubscribed` at model load | Move `CacheProvider` + `RevealKeepAlive` to always-mounted app/page level |
| Inline arrow as `onSelect`/`onLoad` prop | `Maximum update depth exceeded` | `useCallback` at call site; call `onSelect` from `useEffect` inside model browser, never from render |
| Model browser calls `onSelect` during render (`if (revision) onSelect(...)`) | `Maximum update depth exceeded` | Move to `useEffect([revision, onSelect])` |
| Missing `worker.format: 'es'` | Black screen, no error | Add `worker: { format: 'es' }` |
| `react`/`react-dom` missing from `resolve.dedupe` | `ReactCurrentDispatcher` in pnpm monorepo | Add both to `dedupe` |
| Container has no height | Canvas collapses to 0px, nothing renders | Add `height: '70vh'` (or flex/grid height) to the parent element |

Some files were not shown because too many files have changed in this diff Show More