init
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
---
|
||||
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before analysis)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Goal.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Analysis Context
|
||||
|
||||
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices
|
||||
- Data Model references
|
||||
- Phases
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs
|
||||
- Descriptions
|
||||
- Phase grouping
|
||||
- Parallel markers [P]
|
||||
- Referenced file paths
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `.specify/memory/constitution.md` for principle validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%").
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 4. Detection Passes (Token-Efficient Analysis)
|
||||
|
||||
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||
|
||||
#### A. Duplication Detection
|
||||
|
||||
- Identify near-duplicate requirements
|
||||
- Mark lower-quality phrasing for consolidation
|
||||
|
||||
#### B. Ambiguity Detection
|
||||
|
||||
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||
|
||||
#### C. Underspecification
|
||||
|
||||
- Requirements with verbs but missing object or measurable outcome
|
||||
- User stories missing acceptance criteria alignment
|
||||
- Tasks referencing files or components not defined in spec/plan
|
||||
|
||||
#### D. Constitution Alignment
|
||||
|
||||
- Any requirement or plan element conflicting with a MUST principle
|
||||
- Missing mandated sections or quality gates from constitution
|
||||
|
||||
#### E. Coverage Gaps
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
- Terminology drift (same concept named differently across files)
|
||||
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||
|
||||
### 5. Severity Assignment
|
||||
|
||||
Use this heuristic to prioritize findings:
|
||||
|
||||
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||
|
||||
### 6. Produce Compact Analysis Report
|
||||
|
||||
Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
## Specification Analysis Report
|
||||
|
||||
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||
|----|----------|----------|-------------|---------|----------------|
|
||||
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||
|
||||
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||
|
||||
**Coverage Summary Table:**
|
||||
|
||||
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||
|-----------------|-----------|----------|-------|
|
||||
|
||||
**Constitution Alignment Issues:** (if any)
|
||||
|
||||
**Unmapped Tasks:** (if any)
|
||||
|
||||
**Metrics:**
|
||||
|
||||
- Total Requirements
|
||||
- Total Tasks
|
||||
- Coverage % (requirements with >=1 task)
|
||||
- Ambiguity Count
|
||||
- Duplication Count
|
||||
- Critical Issues Count
|
||||
|
||||
### 7. Provide Next Actions
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
### 9. Check for extension hooks
|
||||
|
||||
After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||
|
||||
### Analysis Guidelines
|
||||
|
||||
- **NEVER modify files** (this is read-only analysis)
|
||||
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -0,0 +1,361 @@
|
||||
---
|
||||
description: Generate a custom checklist for the current feature based on user requirements.
|
||||
---
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||
|
||||
**NOT for verification/testing**:
|
||||
|
||||
- ❌ NOT "Verify the button clicks correctly"
|
||||
- ❌ NOT "Test error handling works"
|
||||
- ❌ NOT "Confirm the API returns 200"
|
||||
- ❌ NOT checking if code/implementation matches the spec
|
||||
|
||||
**FOR requirements quality validation**:
|
||||
|
||||
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before checklist generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Execution Steps.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
- Prefer precision over breadth
|
||||
|
||||
Generation algorithm:
|
||||
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||
5. Formulate questions chosen from these archetypes:
|
||||
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||
|
||||
Question formatting rules:
|
||||
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||
- Never ask the user to restate what they already said
|
||||
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||
|
||||
Defaults when interaction impossible:
|
||||
- Depth: Standard
|
||||
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||
- Focus: Top 2 relevance clusters
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
4. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
|
||||
**Context Loading Strategy**:
|
||||
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
- Format: `[domain].md`
|
||||
- File handling behavior:
|
||||
- If file does NOT exist: Create new file and number items starting from CHK001
|
||||
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
|
||||
- Never delete or replace existing checklist content - always preserve and append
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
- **Completeness**: Are all necessary requirements present?
|
||||
- **Clarity**: Are requirements unambiguous and specific?
|
||||
- **Consistency**: Do requirements align with each other?
|
||||
- **Measurability**: Can requirements be objectively verified?
|
||||
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||
|
||||
**Category Structure** - Group items by requirement quality dimensions:
|
||||
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||
|
||||
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||
|
||||
❌ **WRONG** (Testing implementation):
|
||||
- "Verify landing page displays 3 episode cards"
|
||||
- "Test hover states work on desktop"
|
||||
- "Confirm logo click navigates home"
|
||||
|
||||
✅ **CORRECT** (Testing requirements quality):
|
||||
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||
|
||||
**ITEM STRUCTURE**:
|
||||
Each item should follow this pattern:
|
||||
- Question format asking about requirement quality
|
||||
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||
- Use `[Gap]` marker when checking for missing requirements
|
||||
|
||||
**EXAMPLES BY QUALITY DIMENSION**:
|
||||
|
||||
Completeness:
|
||||
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||
|
||||
Clarity:
|
||||
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||
|
||||
Consistency:
|
||||
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||
|
||||
Coverage:
|
||||
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||
|
||||
Measurability:
|
||||
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||
|
||||
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||
|
||||
**Traceability Requirements**:
|
||||
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||
|
||||
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||
Ask questions about the requirements themselves:
|
||||
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||
|
||||
**Content Consolidation**:
|
||||
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||
- Merge near-duplicates checking the same requirement aspect
|
||||
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||
|
||||
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||
- ❌ References to code execution, user actions, system behavior
|
||||
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||
- ❌ Test cases, test plans, QA procedures
|
||||
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||
|
||||
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
|
||||
7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
- Easy identification and navigation in the `checklists/` folder
|
||||
|
||||
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||
|
||||
## Example Checklist Types & Sample Items
|
||||
|
||||
**UX Requirements Quality:** `ux.md`
|
||||
|
||||
Sample items (testing the requirements, NOT the implementation):
|
||||
|
||||
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||
|
||||
**API Requirements Quality:** `api.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||
- "Is versioning strategy documented in requirements? [Gap]"
|
||||
|
||||
**Performance Requirements Quality:** `performance.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||
- "Can performance requirements be objectively measured? [Measurability]"
|
||||
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||
|
||||
**Security Requirements Quality:** `security.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||
|
||||
## Anti-Examples: What NOT To Do
|
||||
|
||||
**❌ WRONG - These test implementation, not requirements:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||
```
|
||||
|
||||
**✅ CORRECT - These test requirements quality:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Wrong: Tests if the system works correctly
|
||||
- Correct: Tests if the requirements are written correctly
|
||||
- Wrong: Verification of behavior
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after checklist generation)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before clarification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_clarify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
- Explicit out-of-scope declarations
|
||||
- User roles / personas differentiation
|
||||
|
||||
Domain & Data Model:
|
||||
- Entities, attributes, relationships
|
||||
- Identity & uniqueness rules
|
||||
- Lifecycle/state transitions
|
||||
- Data volume / scale assumptions
|
||||
|
||||
Interaction & UX Flow:
|
||||
- Critical user journeys / sequences
|
||||
- Error/empty/loading states
|
||||
- Accessibility or localization notes
|
||||
|
||||
Non-Functional Quality Attributes:
|
||||
- Performance (latency, throughput targets)
|
||||
- Scalability (horizontal/vertical, limits)
|
||||
- Reliability & availability (uptime, recovery expectations)
|
||||
- Observability (logging, metrics, tracing signals)
|
||||
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||
- Compliance / regulatory constraints (if any)
|
||||
|
||||
Integration & External Dependencies:
|
||||
- External services/APIs and failure modes
|
||||
- Data import/export formats
|
||||
- Protocol/versioning assumptions
|
||||
|
||||
Edge Cases & Failure Handling:
|
||||
- Negative scenarios
|
||||
- Rate limiting / throttling
|
||||
- Conflict resolution (e.g., concurrent edits)
|
||||
|
||||
Constraints & Tradeoffs:
|
||||
- Technical constraints (language, storage, hosting)
|
||||
- Explicit tradeoffs or rejected alternatives
|
||||
|
||||
Terminology & Consistency:
|
||||
- Canonical glossary terms
|
||||
- Avoided synonyms / deprecated terms
|
||||
|
||||
Completion Signals:
|
||||
- Acceptance criteria testability
|
||||
- Measurable Definition of Done style indicators
|
||||
|
||||
Misc / Placeholders:
|
||||
- TODO markers / unresolved decisions
|
||||
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||
|
||||
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 5 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
4. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
- Common patterns in similar implementations
|
||||
- Risk reduction (security, performance, maintainability)
|
||||
- Alignment with any explicit project goals or constraints visible in the spec
|
||||
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||
- Then render all options as a Markdown table:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| A | <Option A description> |
|
||||
| B | <Option B description> |
|
||||
| C | <Option C description> (add D/E as needed up to 5) |
|
||||
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||
|
||||
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||
- For short‑answer style (no meaningful discrete options):
|
||||
- Provide your **suggested answer** based on best practices and context.
|
||||
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||
- After the user answers:
|
||||
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||
- Stop asking further questions when:
|
||||
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||
- User signals completion ("done", "good", "no more"), OR
|
||||
- You reach 5 asked questions.
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
5. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||
- Then immediately apply the clarification to the most appropriate section(s):
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
6. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
8. Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: $ARGUMENTS
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after clarification)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_clarify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before implementation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||
- Incomplete items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Completed | Incomplete | Status |
|
||||
|-----------|-------|-----------|------------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 incomplete items
|
||||
- **FAIL**: One or more checklists have incomplete items
|
||||
|
||||
- **If any checklist is incomplete**:
|
||||
- Display the table with incomplete item counts
|
||||
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are complete**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Project Setup Verification**:
|
||||
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||
|
||||
**Detection & Creation Logic**:
|
||||
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||
|
||||
```sh
|
||||
git rev-parse --git-dir 2>/dev/null
|
||||
```
|
||||
|
||||
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
|
||||
- Check if .eslintrc* exists → create/verify .eslintignore
|
||||
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
|
||||
- Check if .prettierrc* exists → create/verify .prettierignore
|
||||
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||
- Check if terraform files (*.tf) exist → create/verify .terraformignore
|
||||
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||
|
||||
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||
**If ignore file missing**: Create with full pattern set for detected technology
|
||||
|
||||
**Common Patterns by Technology** (from plan.md tech stack):
|
||||
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
|
||||
**Tool-Specific Patterns**:
|
||||
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||
|
||||
5. Parse tasks.md structure and extract:
|
||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||
- **Task dependencies**: Sequential vs parallel execution rules
|
||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||
- **Execution flow**: Order and dependency requirements
|
||||
|
||||
6. Execute implementation following the task plan:
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||
- **Integration work**: Database connections, middleware, logging, external services
|
||||
- **Polish and validation**: Unit tests, performance optimization, documentation
|
||||
|
||||
8. Progress tracking and error handling:
|
||||
- Report progress after each completed task
|
||||
- Halt execution if any non-parallel task fails
|
||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||
- Provide clear error messages with context for debugging
|
||||
- Suggest next steps if implementation cannot proceed
|
||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||
|
||||
9. Completion validation:
|
||||
- Verify all required tasks are completed
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that tests pass and coverage meets requirements
|
||||
- Confirm the implementation follows the technical plan
|
||||
- Report final status with summary of completed work
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
|
||||
|
||||
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
|
||||
handoffs:
|
||||
- label: Create Tasks
|
||||
agent: speckit.tasks
|
||||
prompt: Break the plan into tasks
|
||||
send: true
|
||||
- label: Create Checklist
|
||||
agent: speckit.checklist
|
||||
prompt: Create a checklist for the following domain...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before planning)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||
|
||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||
- Fill Constitution Check section from constitution
|
||||
- Evaluate gates (ERROR if violations unjustified)
|
||||
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||
- Phase 1: Update agent context by running the agent script
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Outline & Research
|
||||
|
||||
1. **Extract unknowns from Technical Context** above:
|
||||
- For each NEEDS CLARIFICATION → research task
|
||||
- For each dependency → best practices task
|
||||
- For each integration → patterns task
|
||||
|
||||
2. **Generate and dispatch research agents**:
|
||||
|
||||
```text
|
||||
For each unknown in Technical Context:
|
||||
Task: "Research {unknown} for {feature context}"
|
||||
For each technology choice:
|
||||
Task: "Find best practices for {tech} in {domain}"
|
||||
```
|
||||
|
||||
3. **Consolidate findings** in `research.md` using format:
|
||||
- Decision: [what was chosen]
|
||||
- Rationale: [why chosen]
|
||||
- Alternatives considered: [what else evaluated]
|
||||
|
||||
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||
|
||||
### Phase 1: Design & Contracts
|
||||
|
||||
**Prerequisites:** `research.md` complete
|
||||
|
||||
1. **Extract entities from feature spec** → `data-model.md`:
|
||||
- Entity name, fields, relationships
|
||||
- Validation rules from requirements
|
||||
- State transitions if applicable
|
||||
|
||||
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
|
||||
- Identify what interfaces the project exposes to users or other systems
|
||||
- Document the contract format appropriate for the project type
|
||||
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
|
||||
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||
|
||||
3. **Agent context update**:
|
||||
- Update the plan reference between the `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` markers in `AGENTS.md` to point to the plan file created in step 1 (the IMPL_PLAN path)
|
||||
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file
|
||||
|
||||
## Key rules
|
||||
|
||||
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files
|
||||
- ERROR on gate failures or unresolved clarifications
|
||||
@@ -0,0 +1,327 @@
|
||||
---
|
||||
description: Create or update the feature specification from a natural language feature description.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
- label: Clarify Spec Requirements
|
||||
agent: speckit.clarify
|
||||
prompt: Clarify specification requirements
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before specification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
1. **Generate a concise short name** (2-4 words) for the feature:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Create a 2-4 word short name that captures the essence of the feature
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
- Keep it concise but descriptive enough to understand the feature at a glance
|
||||
- Examples:
|
||||
- "I want to add user authentication" → "user-auth"
|
||||
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Branch creation** (optional, via hook):
|
||||
|
||||
If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name.
|
||||
|
||||
If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation).
|
||||
|
||||
3. **Create the spec feature directory**:
|
||||
|
||||
Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`.
|
||||
|
||||
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
|
||||
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
|
||||
2. Otherwise, auto-generate it under `specs/`:
|
||||
- Check `.specify/init-options.json` for `branch_numbering`
|
||||
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
||||
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
||||
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
||||
|
||||
**Create the directory and spec file**:
|
||||
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
||||
- Copy `.specify/templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
|
||||
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
|
||||
- Persist the resolved path to `.specify/feature.json`:
|
||||
```json
|
||||
{
|
||||
"feature_directory": "<resolved feature dir>"
|
||||
}
|
||||
```
|
||||
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
|
||||
This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
|
||||
|
||||
**IMPORTANT**:
|
||||
- You must only create one feature per `/speckit.specify` invocation
|
||||
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
||||
- The spec directory and file are always created by this command, never by the hook
|
||||
|
||||
4. Load `.specify/templates/spec-template.md` to understand required sections.
|
||||
|
||||
5. Follow this execution flow:
|
||||
1. Parse user description from arguments
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
Identify: actors, actions, data, constraints
|
||||
3. For unclear aspects:
|
||||
- Make informed guesses based on context and industry standards
|
||||
- Only mark with [NEEDS CLARIFICATION: specific question] if:
|
||||
- The choice significantly impacts feature scope or user experience
|
||||
- Multiple reasonable interpretations exist with different implications
|
||||
- No reasonable default exists
|
||||
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
|
||||
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
|
||||
4. Fill User Scenarios & Testing section
|
||||
If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||
5. Generate Functional Requirements
|
||||
Each requirement must be testable
|
||||
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
|
||||
6. Define Success Criteria
|
||||
Create measurable, technology-agnostic outcomes
|
||||
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
|
||||
Each criterion must be verifiable without implementation details
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md]
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [ ] No implementation details (languages, frameworks, APIs)
|
||||
- [ ] Focused on user value and business needs
|
||||
- [ ] Written for non-technical stakeholders
|
||||
- [ ] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||
- [ ] Requirements are testable and unambiguous
|
||||
- [ ] Success criteria are measurable
|
||||
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||
- [ ] All acceptance scenarios are defined
|
||||
- [ ] Edge cases are identified
|
||||
- [ ] Scope is clearly bounded
|
||||
- [ ] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [ ] All functional requirements have clear acceptance criteria
|
||||
- [ ] User scenarios cover primary flows
|
||||
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [ ] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
```
|
||||
|
||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||
- For each item, determine if it passes or fails
|
||||
- Document specific issues found (quote relevant spec sections)
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 7
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
2. Update the spec to address each issue
|
||||
3. Re-run validation until all items pass (max 3 iterations)
|
||||
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
|
||||
|
||||
- **If [NEEDS CLARIFICATION] markers remain**:
|
||||
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
|
||||
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
|
||||
3. For each clarification needed (max 3), present options to user in this format:
|
||||
|
||||
```markdown
|
||||
## Question [N]: [Topic]
|
||||
|
||||
**Context**: [Quote relevant spec section]
|
||||
|
||||
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||
|
||||
**Suggested Answers**:
|
||||
|
||||
| Option | Answer | Implications |
|
||||
|--------|--------|--------------|
|
||||
| A | [First suggested answer] | [What this means for the feature] |
|
||||
| B | [Second suggested answer] | [What this means for the feature] |
|
||||
| C | [Third suggested answer] | [What this means for the feature] |
|
||||
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||
|
||||
**Your choice**: _[Wait for user response]_
|
||||
```
|
||||
|
||||
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
|
||||
- Use consistent spacing with pipes aligned
|
||||
- Each cell should have spaces around content: `| Content |` not `|Content|`
|
||||
- Header separator must have at least 3 dashes: `|--------|`
|
||||
- Test that the table renders correctly in markdown preview
|
||||
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
|
||||
6. Present all questions together before waiting for responses
|
||||
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
|
||||
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
|
||||
9. Re-run validation after all clarifications are resolved
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
8. **Report completion** to the user with:
|
||||
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||
- `SPEC_FILE` — the spec file path
|
||||
- Checklist results summary
|
||||
- Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`)
|
||||
|
||||
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
- Focus on **WHAT** users need and **WHY**.
|
||||
- Avoid HOW to implement (no tech stack, APIs, code structure).
|
||||
- Written for business stakeholders, not developers.
|
||||
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
|
||||
|
||||
### Section Requirements
|
||||
|
||||
- **Mandatory sections**: Must be completed for every feature
|
||||
- **Optional sections**: Include only when relevant to the feature
|
||||
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||
|
||||
### For AI Generation
|
||||
|
||||
When creating this spec from a user prompt:
|
||||
|
||||
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
|
||||
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
|
||||
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
|
||||
- Significantly impact feature scope or user experience
|
||||
- Have multiple reasonable interpretations with different implications
|
||||
- Lack any reasonable default
|
||||
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
|
||||
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||
6. **Common areas needing clarification** (only if no reasonable default exists):
|
||||
- Feature scope and boundaries (include/exclude specific use cases)
|
||||
- User types and permissions (if multiple conflicting interpretations possible)
|
||||
- Security/compliance requirements (when legally/financially significant)
|
||||
|
||||
**Examples of reasonable defaults** (don't ask about these):
|
||||
|
||||
- Data retention: Industry-standard practices for the domain
|
||||
- Performance targets: Standard web/mobile app expectations unless specified
|
||||
- Error handling: User-friendly messages with appropriate fallbacks
|
||||
- Authentication method: Standard session-based or OAuth2 for web apps
|
||||
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
|
||||
|
||||
### Success Criteria Guidelines
|
||||
|
||||
Success criteria must be:
|
||||
|
||||
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
|
||||
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
|
||||
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
|
||||
4. **Verifiable**: Can be tested/validated without knowing implementation details
|
||||
|
||||
**Good examples**:
|
||||
|
||||
- "Users can complete checkout in under 3 minutes"
|
||||
- "System supports 10,000 concurrent users"
|
||||
- "95% of searches return results in under 1 second"
|
||||
- "Task completion rate improves by 40%"
|
||||
|
||||
**Bad examples** (implementation-focused):
|
||||
|
||||
- "API response time is under 200ms" (too technical, use "Users see results instantly")
|
||||
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||
- "React components render efficiently" (framework-specific)
|
||||
- "Redis cache hit rate above 80%" (technology-specific)
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
|
||||
handoffs:
|
||||
- label: Analyze For Consistency
|
||||
agent: speckit.analyze
|
||||
prompt: Run a project analysis for consistency
|
||||
send: true
|
||||
- label: Implement Project
|
||||
agent: speckit.implement
|
||||
prompt: Start the implementation in phases
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
- Load plan.md and extract tech stack, libraries, project structure
|
||||
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||
- If data-model.md exists: Extract entities and map to user stories
|
||||
- If contracts/ exists: Map interface contracts to user stories
|
||||
- If research.md exists: Extract decisions for setup tasks
|
||||
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||
- Generate dependency graph showing user story completion order
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure, fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||
- Final Phase: Polish & cross-cutting concerns
|
||||
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||
- Clear file paths for each task
|
||||
- Dependencies section showing story completion order
|
||||
- Parallel execution examples per story
|
||||
- Implementation strategy section (MVP first, incremental delivery)
|
||||
|
||||
5. **Report**: Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
Context for task generation: $ARGUMENTS
|
||||
|
||||
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||
|
||||
## Task Generation Rules
|
||||
|
||||
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||
|
||||
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
|
||||
|
||||
### Checklist Format (REQUIRED)
|
||||
|
||||
Every task MUST strictly follow this format:
|
||||
|
||||
```text
|
||||
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||
```
|
||||
|
||||
**Format Components**:
|
||||
|
||||
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||
- Setup phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- User Story phases: MUST have story label
|
||||
- Polish phase: NO story label
|
||||
5. **Description**: Clear action with exact file path
|
||||
|
||||
**Examples**:
|
||||
|
||||
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||
|
||||
### Task Organization
|
||||
|
||||
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||
- Each user story (P1, P2, P3...) gets its own phase
|
||||
- Map all related components to their story:
|
||||
- Models needed for that story
|
||||
- Services needed for that story
|
||||
- Interfaces/UI needed for that story
|
||||
- If tests requested: Tests specific to that story
|
||||
- Mark story dependencies (most stories should be independent)
|
||||
|
||||
2. **From Contracts**:
|
||||
- Map each interface contract → to the user story it serves
|
||||
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase
|
||||
|
||||
3. **From Data Model**:
|
||||
- Map each entity to the user story(ies) that need it
|
||||
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||
- Relationships → service layer tasks in appropriate story phase
|
||||
|
||||
4. **From Setup/Infrastructure**:
|
||||
- Shared infrastructure → Setup phase (Phase 1)
|
||||
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||
- Story-specific setup → within that story's phase
|
||||
|
||||
### Phase Structure
|
||||
|
||||
- **Phase 1**: Setup (project initialization)
|
||||
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||
- Each phase should be a complete, independently testable increment
|
||||
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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** | 10k–100k 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.
|
||||
@@ -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`.
|
||||
@@ -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>
|
||||
@@ -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 (H1–H6) 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 30–40% 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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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` | Mid–high | 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 = 7th–8th grade; Mid = 9th–10th grade; High = 10th–11th 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. 2–4 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. 10–15 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 8–14 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 | 2–4 words | 6 words |
|
||||
| Titles | 3–6 words, 40 characters | — |
|
||||
| Tooltips | 10–20 words | 2 sentences |
|
||||
| Error messages | 12–18 words | — |
|
||||
| Instructions | 14 words | 20 words |
|
||||
| Notifications | 10–15 words total | — |
|
||||
| Line length | 40–60 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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
|
||||
@@ -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 2–5.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -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 side’s 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 main’s global behavior or the branch’s 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 branch’s 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">.
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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 mount→unmount→remount 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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user