diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 944262d7..134aa5d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -480,7 +480,7 @@ jobs: id: playwright-cache with: path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('apps/papillon/e2e/package-lock.json', 'e2e/package.json') }} + key: playwright-${{ runner.os }}-${{ hashFiles('apps/papillon/e2e/package-lock.json') }} restore-keys: playwright-${{ runner.os }}- - name: Install npm dependencies working-directory: apps/papillon/e2e @@ -567,7 +567,7 @@ jobs: id: playwright-cache-tier2 with: path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('apps/papillon/e2e/package-lock.json', 'e2e/package.json') }} + key: playwright-${{ runner.os }}-${{ hashFiles('apps/papillon/e2e/package-lock.json') }} restore-keys: playwright-${{ runner.os }}- - name: Install npm dependencies @@ -632,13 +632,29 @@ jobs: with: node-version: "20" cache: npm - cache-dependency-path: apps/registry/e2e/package.json + cache-dependency-path: apps/registry/e2e/package-lock.json + + - name: Cache Playwright browsers + uses: actions/cache@v5 + id: playwright-cache-fed + with: + path: ~/.cache/ms-playwright + key: playwright-fed-${{ runner.os }}-${{ hashFiles('apps/registry/e2e/package-lock.json') }} + restore-keys: playwright-fed-${{ runner.os }}- - name: Install Playwright dependencies working-directory: apps/registry/e2e - run: | - npm ci - npx playwright install --with-deps chromium + run: npm ci + + - name: Install Playwright browsers + working-directory: apps/registry/e2e + if: steps.playwright-cache-fed.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + - name: Install Playwright system deps only + working-directory: apps/registry/e2e + if: steps.playwright-cache-fed.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium - name: Run federation sync tests working-directory: apps/registry/e2e diff --git a/.github/workflows/web-build.yml b/.github/workflows/web-build.yml index 636478eb..25b44afe 100644 --- a/.github/workflows/web-build.yml +++ b/.github/workflows/web-build.yml @@ -84,7 +84,7 @@ jobs: id: playwright-cache with: path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('apps/papillon/e2e/package-lock.json', 'e2e/package.json') }} + key: playwright-${{ runner.os }}-${{ hashFiles('apps/papillon/e2e/package-lock.json') }} restore-keys: playwright-${{ runner.os }}- - name: Install npm dependencies diff --git a/Cargo.lock b/Cargo.lock index cd43253e..4e614781 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4724,7 +4724,7 @@ dependencies = [ [[package]] name = "pap-agents" -version = "0.8.2" +version = "0.8.3" dependencies = [ "candle-core", "candle-transformers", @@ -4753,7 +4753,7 @@ dependencies = [ [[package]] name = "pap-bench" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "criterion", @@ -4772,7 +4772,7 @@ dependencies = [ [[package]] name = "pap-bluefield" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -4791,7 +4791,7 @@ dependencies = [ [[package]] name = "pap-bluefield-loopback" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-bluefield", @@ -4804,7 +4804,7 @@ dependencies = [ [[package]] name = "pap-c" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "cbindgen", @@ -4822,7 +4822,7 @@ dependencies = [ [[package]] name = "pap-core" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", @@ -4842,7 +4842,7 @@ dependencies = [ [[package]] name = "pap-credential" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", @@ -4861,7 +4861,7 @@ dependencies = [ [[package]] name = "pap-credential-lifecycle-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "pap-credential-store" -version = "0.8.2" +version = "0.8.3" dependencies = [ "aes-gcm", "argon2", @@ -4897,7 +4897,7 @@ dependencies = [ [[package]] name = "pap-delegation-chain-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-core", @@ -4907,7 +4907,7 @@ dependencies = [ [[package]] name = "pap-did" -version = "0.8.2" +version = "0.8.3" dependencies = [ "bs58", "ed25519-dalek", @@ -4920,7 +4920,7 @@ dependencies = [ [[package]] name = "pap-ecash" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "blind-rsa-signatures", @@ -4932,7 +4932,7 @@ dependencies = [ [[package]] name = "pap-federated-discovery-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -4948,7 +4948,7 @@ dependencies = [ [[package]] name = "pap-federation" -version = "0.8.2" +version = "0.8.3" dependencies = [ "axum", "axum-server", @@ -4985,7 +4985,7 @@ dependencies = [ [[package]] name = "pap-intent-routing" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-agents", @@ -4998,7 +4998,7 @@ dependencies = [ [[package]] name = "pap-marketplace" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", @@ -5016,7 +5016,7 @@ dependencies = [ [[package]] name = "pap-networked-search-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "axum", "chrono", @@ -5032,7 +5032,7 @@ dependencies = [ [[package]] name = "pap-payment-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-core", @@ -5044,7 +5044,7 @@ dependencies = [ [[package]] name = "pap-proto" -version = "0.8.2" +version = "0.8.3" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -5066,7 +5066,7 @@ dependencies = [ [[package]] name = "pap-protocol-envelope-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -5079,7 +5079,7 @@ dependencies = [ [[package]] name = "pap-python" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -5097,7 +5097,7 @@ dependencies = [ [[package]] name = "pap-registry" -version = "0.8.2" +version = "0.8.3" dependencies = [ "anyhow", "axum", @@ -5146,7 +5146,7 @@ dependencies = [ [[package]] name = "pap-sandbox" -version = "0.8.2" +version = "0.8.3" dependencies = [ "aes-gcm", "async-trait", @@ -5173,7 +5173,7 @@ dependencies = [ [[package]] name = "pap-search-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-core", @@ -5185,7 +5185,7 @@ dependencies = [ [[package]] name = "pap-selective-disclosure-decay-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "ed25519-dalek", "pap-core", @@ -5197,7 +5197,7 @@ dependencies = [ [[package]] name = "pap-tee" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", @@ -5212,7 +5212,7 @@ dependencies = [ [[package]] name = "pap-test-utils" -version = "0.8.2" +version = "0.8.3" dependencies = [ "ed25519-dalek", "pap-did", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "pap-transport" -version = "0.8.2" +version = "0.8.3" dependencies = [ "aes-gcm", "axum", @@ -5250,7 +5250,7 @@ dependencies = [ [[package]] name = "pap-travel-booking-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-core", @@ -5262,7 +5262,7 @@ dependencies = [ [[package]] name = "pap-wasm" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -5284,7 +5284,7 @@ dependencies = [ [[package]] name = "pap-webauthn" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", @@ -5301,7 +5301,7 @@ dependencies = [ [[package]] name = "pap-webauthn-ceremony-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -5313,7 +5313,7 @@ dependencies = [ [[package]] name = "papillon" -version = "0.8.2" +version = "0.8.3" dependencies = [ "axum", "axum-server", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "papillon-shared" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -8209,7 +8209,7 @@ dependencies = [ [[package]] name = "tee-attestation" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", diff --git a/E2E_TESTING_PLAN.md b/E2E_TESTING_PLAN.md deleted file mode 100644 index 0359ff83..00000000 --- a/E2E_TESTING_PLAN.md +++ /dev/null @@ -1,283 +0,0 @@ -# E2E Testing Strategy for Papillon - -## Problem - -The release workflow builds Papillon for macOS/Linux/Windows but **never tests if the app actually works**. - -- ✓ CI tests the Rust crates (unit + integration tests) -- ✓ Release workflow builds the Tauri desktop app -- ✗ **No verification that the built app launches, renders UI, or responds to user input** - -Result: The blank UI bug in v0.3.0 was only caught after manually building and installing. The release was published with a broken app. - -## Solution: Three Tiers of E2E Testing - -### Tier 1: Smoke Test (Fast, CI-Friendly) — In CI -**When:** Every PR + every build in release workflow -**What:** Verify the app can: -- Launch without crashing -- Load the main window -- Render content (not blank screen) -- Respond to window events - -**Tools:** Tauri automation via `tauri` CLI + screenshot verification - -**Cost:** ~2 min per platform, ~6 min total (macOS + Linux + Windows in parallel) - -**Deliverable:** Screenshot proof + pass/fail verdict - ---- - -### Tier 2: Functional Test (Medium, Post-Build) — In Release Workflow -**When:** After each platform build in release.yml -**What:** Verify key UI flows: -- Principal setup flow (DID generation) -- Agent marketplace browsing -- Mandate creation/signing -- Session handshake simulation - -**Tools:** Tauri app automation + assertion framework (custom Rust harness) - -**Cost:** ~5 min per platform, run serially after build - -**Deliverable:** Test report uploaded to release as artifact - ---- - -### Tier 3: Canary Test (Deep, Production Only) — Post-Deploy -**When:** After release is published -**What:** Download the built app, install it, run full protocol scenarios -- Real marketplace agent discovery -- End-to-end delegation chain -- Receipt verification - -**Tools:** gstack `/canary` skill (already available for web, extend for desktop) - -**Cost:** Run once, ~10 min - -**Deliverable:** Health report vs baseline - ---- - -## Implementation: Start with Tier 1 - -### Step 1: Add Smoke Test to CI (`.github/workflows/ci.yml`) - -```bash -# New job: smoke-test-papillon -# Runs on Linux only (fastest, same code path as Windows/macOS for most issues) -# 1. Build the app in release mode -# 2. Launch it headless with Tauri automation -# 3. Wait for window to appear and render -# 4. Take screenshot -# 5. Check: not blank, not error screen -# 6. Close app -# 7. Exit 0 if pass, 1 if fail -``` - -**Estimated effort:** 50 lines of YAML + 150 lines of Rust harness = 30 min - ---- - -### Step 2: Add Platform-Specific Smoke Tests to Release Workflow - -After the `build` job (lines 84-157), add a new `smoke-test` job that: - -```bash -# Matrix over [macos-latest, ubuntu-22.04, windows-latest] -# For each platform, after build succeeds: -# 1. Run the built app binary -# 2. Verify window appears and loads content -# 3. Capture screenshot -# 4. Upload screenshot as artifact -``` - -**Integration point:** Runs after `tauri build` in the existing `build` job, or as a separate downstream job that takes built artifacts. - -**Estimated effort:** 80 lines YAML + 200 lines Rust harness = 1 hour - ---- - -### Step 3: Create Tauri App Harness (`crates/papillon-test/`) - -New test crate with helpers: - -```rust -// Launch app instance -fn launch_papillon_dev() -> Result -fn launch_papillon_release(path: &Path) -> Result - -// Assertions -fn assert_window_exists(app: &mut Child, timeout: Duration) -> Result<()> -fn assert_content_visible(app: &mut Child) -> Result // Returns screenshot -fn assert_no_console_errors(app: &mut Child) -> Result<()> - -// Cleanup -impl Drop for PapillonApp { fn drop(&mut self) { kill() } } -``` - -**Estimated effort:** 200 lines = 45 min - ---- - -### Step 4: Local Development Convenience - -Add npm scripts to `apps/papillon/frontend/package.json`: - -```json -"test:e2e:dev": "cargo run -p papillon-test -- --dev", -"test:e2e:release": "cargo build -p papillon --release && cargo run -p papillon-test -- --release target/release/Papillon" -``` - -So developers can verify locally before pushing. - ---- - -## Files to Create/Modify - -| File | Action | LOC | -|------|--------|-----| -| `.github/workflows/ci.yml` | Add smoke-test job | +50 | -| `.github/workflows/release.yml` | Add platform-specific smoke-test | +80 | -| `crates/papillon-test/` | New test crate | +300 | -| `Cargo.toml` (root) | Add workspace member | +2 | -| `apps/papillon/frontend/package.json` | Add npm scripts | +4 | - -**Total effort:** ~2 hours - -**Payoff:** Never ship a blank UI again. Catches regression in 6 minutes flat. - ---- - -## Critical: What to Test - -**ALWAYS test these (regression-proof):** - -1. **Window launches** — app binary runs without panic -2. **Frontend renders** — not a blank/white screen (verify pixels ≠ white) -3. **No console errors** — WASM module loads successfully -4. **Basic interaction** — buttons respond (e.g., click → no crash) - -**Optional (can add later):** - -- Protocol handshakes -- Agent marketplace queries -- Mandate creation - ---- - -## Why This Matters - -The blank UI bug cost us: -- Public release of broken app (v0.3.0) -- Manual debugging after-the-fact -- User trust erosion - -With Tier 1 E2E (smoke test), this bug would have been caught **in the release workflow** before publishing. - -Cost of prevention: 2 hours now. -Cost of recurrence: Reputation + future rework. - ---- - -## TIER 1 IMPLEMENTATION ✅ COMPLETE - -**Status:** Implemented on branch `vk/1f28-fix-tauri-releas` -**Date Completed:** 2026-03-23 -**Commits:** `d538381` — feat(qa): add Tier 1 smoke tests for Papillon desktop app - -### Files Created/Modified - -1. **`e2e/tests/smoke.spec.ts`** [NEW] — Smoke test suite (170 lines) - - 5 focused test cases - - Tests: app launch, rendering, console errors, interaction, WASM load - - Uses existing Playwright + Tauri mocking infrastructure - - Expected duration: ~30 seconds - -2. **`.github/workflows/ci.yml`** [EDITED] — Added smoke-test job (+40 lines) - - Runs on every PR and push to main - - Builds Tauri app in release mode - - Executes smoke tests - - Uploads artifacts on failure - - Total duration: ~5 minutes - -3. **`.github/workflows/release.yml`** [EDITED] — Added post-build verification (+20 lines) - - Verifies built binaries exist for each platform - - Platform-specific artifact checks (macOS .app, Linux AppImage, Windows .msi) - -4. **`e2e/package.json`** [EDITED] — Added npm scripts (+2 lines) - - `npm run test:smoke` — Run smoke tests - - `npm run test:smoke:headed` — Run with visible browser - -### How to Run Locally - -```bash -# Prerequisites: Must have built the app first -cd apps/papillon -cargo tauri build - -# Run smoke tests -npm run test:smoke - -# Run with visible browser (debugging) -npm run test:smoke:headed -``` - -### CI Integration - -**When smoke tests run:** -- ✅ Every PR to main (before merge) -- ✅ Every push to main (before release) -- ✅ Every platform build in release workflow (macOS/Linux/Windows) - -**What happens on failure:** -- Test artifacts uploaded to GitHub Actions -- Screenshots captured for debugging -- CI job fails (blocks merge/release) - -### Test Coverage - -**Verification:** -- ✅ App window launches and is visible -- ✅ Frontend renders content (not blank) -- ✅ No unhandled JS console errors on startup -- ✅ Basic interaction works (buttons respond) -- ✅ WASM module loaded correctly - -**What it catches:** -- Blank UI bugs (like v0.3.0) -- Missing frontend bundle -- Build configuration errors -- JavaScript compilation failures -- Startup crashes - -### Regression Prevention - -**Before Tier 1:** v0.3.0 blank UI bug shipped to users -**After Tier 1:** Future blank UI bugs caught in CI before release - -**Cost:** ~2 hours implementation -**Value:** Prevents 1 ship-blocker-level regression per release cycle - -### Next Steps (Tier 2 & 3) - -1. **Tier 2 (Functional Test):** Test key UI flows (DID generation, agent discovery, mandates) -2. **Tier 3 (Canary Test):** Post-deploy monitoring of production app - ---- - -## How This Solves the v0.3.0 Problem - -**Timeline (v0.3.0):** -- ❌ Release workflow built app (but didn't test it) -- ❌ App shipped with blank UI -- ❌ Users downloaded broken app -- ✓ Bug caught after public release - -**Timeline (with Tier 1):** -- ✓ Release workflow builds app -- ✓ Smoke test verifies app launches and renders -- ✓ CI catches blank UI before release -- ✓ Bug fixed before binary published -- ✓ Users get working app - diff --git a/FIXES_APPLIED.md b/FIXES_APPLIED.md deleted file mode 100644 index 6cfc83aa..00000000 --- a/FIXES_APPLIED.md +++ /dev/null @@ -1,100 +0,0 @@ -# Fixes Applied to `codex-papillon-canvas-browser-runtime` Branch - -## 🐛 Issues Fixed - -### 1. Silent Approval Errors → Block Stuck Forever -**Symptom**: Clicking "Authorize" on approval gates does nothing, block stays stuck -**Root Cause**: `approve_block()` and `reject_block()` were silently discarding backend errors with `let _ = invoke(...).await` -**Fix**: Added proper error handling with match statements, error logging, and state cleanup -**Commit**: `a450be42` - -### 2. Silent Persistence Failures → Orphaned Blocks -**Symptom**: Backend returns "Block not found: 19deb3f4cfa-721e55c5" -**Root Cause**: `canvas_block_create` failed silently → block only in UI memory, not DB -**Fix**: Added error logging for persistence failures, continues gracefully -**Commit**: `a450be42` - -### 3. Orphaned Blocks → Retry Fails -**Symptom**: "Try again" button triggers `canvas_retry` which fails with "no definitions found" -**Root Cause**: Retry attempts to use block ID that was never persisted to DB -**Fix**: Auto-recovery system that detects orphaned blocks, deletes them, and creates fresh prompts -**Commit**: `ae93a38c` - -### 4. Setup Overlay Blocks All Clicks -**Symptom**: `.setup-overlay` intercepts pointer events across entire screen -**Root Cause**: Missing `pointer-events: none` on overlay, causing it to block clicks behind it -**Fix**: Added `pointer-events: none` to overlay, `pointer-events: auto` to wizard -**Commit**: `a693c8f3` -**Discovered By**: Playwright automated testing - ---- - -## ✅ What Works Now - -### Error Visibility -```javascript -// Console now shows: -ERROR approve_block failed for abc-123: Block not found -ERROR Failed to persist block abc-123 to DB: [reason]. Block will remain in memory only. -WARN Approval already in flight for abc-123, ignoring duplicate request -WARN Retry failed because block not in DB. Creating fresh prompt instead. -``` - -### Auto-Recovery -1. User clicks "Try again" on failed block -2. System detects "Block not found" error -3. Orphaned UI block is automatically deleted -4. Fresh prompt is created with same text -5. Workflow continues without manual intervention - -### UI Interaction -- Setup wizard overlay no longer blocks clicks outside the wizard box -- Can interact with workflow view even when wizard is present - ---- - -## 🧪 Testing - -### Manual Test -1. Start Papillon: `just papillon` -2. Open http://127.0.0.1:1420/ -3. Enter workflow: "research airline tickets to cabo" -4. Click "Try again" if it fails -5. Check browser console (F12) for error messages - -### Automated Test -```bash -node test-papillon.js -``` -Screenshots saved to: -- `papillon-01-initial.png` - Initial load -- `papillon-02-setup-overlay.png` - Setup wizard if present -- `papillon-03-*.png` - Workflow interactions -- etc. - ---- - -## 📊 Impact - -**Before**: Silent failures, stuck blocks, no way to recover -**After**: Clear error messages, automatic recovery, graceful degradation - -**Commits**: -- `a450be42` - Error handling for approval/persistence -- `ae93a38c` - Auto-recovery from orphaned blocks -- `a693c8f3` - Setup overlay click-through fix -- `47381d8a` - Playwright test infrastructure - -**Files Changed**: -- `apps/papillon/frontend/src/state/canvas.rs` (+104 lines) -- `apps/papillon/frontend/styles/main.css` (+2 lines) -- `test-papillon.js` (new file) - ---- - -## 🎯 Next Steps - -1. **Test the fixes** - Run Papillon and verify retry works -2. **Monitor console** - Check for new error patterns -3. **Consider merging** - These fixes should go to main branch -4. **Add tests** - Unit tests for error handling paths diff --git a/WEB_BUILD_IMPLEMENTATION.md b/WEB_BUILD_IMPLEMENTATION.md deleted file mode 100644 index f6ebce5b..00000000 --- a/WEB_BUILD_IMPLEMENTATION.md +++ /dev/null @@ -1,306 +0,0 @@ -# Tauri Web Build with SQLite WASM Support - Implementation Complete ✅ - -**Date**: 2026-03-23 -**Status**: Ready for CI/CD testing - -## Overview - -This implementation enables building Papillon as a pure web application (WASM) while maintaining full compatibility with the desktop Tauri build. The key innovation is a **database abstraction layer** that allows the same Rust codebase to compile for both native (rusqlite) and web (sql.js) targets using feature flags. - -## Architecture - -### Database Abstraction Layer - -**Location**: `crates/papillon-shared/src/db/` - -The abstraction uses Rust's type system and feature flags to provide compile-time database backend selection: - -```rust -// mod.rs - Public trait defining the interface -pub trait DatabaseOps: Send + Sync { - fn insert_episode(&self, episode: &Episode) -> Result<(), DbError>; - fn list_episodes(...) -> Result, DbError>; - fn upsert_agent_profile(&self, profile: &AgentProfile) -> Result<(), DbError>; - // ... 7 more methods -} - -// native.rs - Desktop implementation (rusqlite) -#[cfg(feature = "native")] -pub struct NativeDatabase { conn: Mutex } -impl DatabaseOps for NativeDatabase { /* rusqlite implementation */ } - -// wasm.rs - Web implementation stub (sql.js) -#[cfg(feature = "wasm")] -pub struct WasmDatabase { /* sql.js connection */ } -impl DatabaseOps for WasmDatabase { /* placeholder for sql.js */ } -``` - -### Feature Flags - -**`papillon-shared/Cargo.toml`**: -```toml -[features] -default = ["native"] -native = ["rusqlite"] # Only includes rusqlite when native is enabled -wasm = [] # Excludes rusqlite for web builds -``` - -This ensures: -- ✅ Desktop builds (`native` feature) include rusqlite -- ✅ Web builds (`wasm` feature) exclude rusqlite entirely -- ✅ No conflicts or duplicated dependencies - -### Build Targets - -| Target | Features | Database | Binary Size | Platform | -|--------|----------|----------|-------------|----------| -| Desktop | `native` | rusqlite | ~150MB | Linux, macOS, Windows | -| Web | `wasm` | sql.js (stub) | ~5MB | Browser (all platforms) | - -## File Changes - -### New Files Created - -1. **`crates/papillon-shared/src/db/mod.rs`** (100 lines) - - `DatabaseOps` trait definition - - Shared `Episode` and `AgentProfile` types - - `DbError` wrapper type - - Feature-gated re-exports - -2. **`crates/papillon-shared/src/db/native.rs`** (400+ lines) - - Complete rusqlite implementation - - Schema definition (episodes, agent_profiles, retention_policies, settings) - - All 9 DatabaseOps trait methods - - Existing tests ported from original db.rs - -3. **`crates/papillon-shared/src/db/wasm.rs`** (80 lines) - - Placeholder WasmDatabase struct - - All DatabaseOps trait methods (stubs, ready for sql.js) - - Framework for IndexedDB persistence integration - -4. **`.github/workflows/web-build.yml`** (100 lines) - - CI job to build web target with Trunk - - Checks WASM compilation on every PR - - Uploads web artifacts - - Basic health check (verifies HTTP server can serve index.html) - -### Modified Files - -1. **`crates/papillon-shared/Cargo.toml`** - - Added `rusqlite` as optional dependency - - Added feature flags: `native` (default), `wasm` - - `chrono`, `serde`, `serde_json` remain unconditional - -2. **`crates/papillon-shared/src/lib.rs`** - - Export `db` module when either feature is enabled - -3. **`apps/papillon/src/db.rs`** (→ 35 lines) - - Changed from full implementation to re-export + compatibility layer - - Type alias: `pub type Database = NativeDatabase;` - - Helper functions: `open_db()`, `open_db_memory()` - - Error conversion: `DbError` → `PapillonError` - -4. **`apps/papillon/Cargo.toml`** - - Updated `papillon-shared` to use `features = ["native"]` - - Re-added `rusqlite` direct dependency (for `profiles_db.rs`) - -5. **`apps/papillon/src/state.rs`** - - Updated all `Database::open()` calls to `crate::db::open_db()` - - Updated all `Database::open_memory()` calls to `crate::db::open_db_memory()` - - Added `use crate::db::prelude::DatabaseOps` to scope trait methods - -6. **`apps/papillon/src/commands/{orchestrator,identity,registry}.rs`** - - Added `use crate::db::prelude::DatabaseOps` to all files - - Updated `list_agent_profiles()` and `set_setting()` calls with `.map_err()` conversions - -7. **`apps/papillon/frontend/Cargo.toml`** - - Updated `papillon-shared` to use `default-features = false, features = ["wasm"]` - - Ensures web builds don't pull in rusqlite - -## Build Instructions - -### Desktop (Native) Build - -```bash -# Standard desktop app build (no changes to existing workflow) -cd apps/papillon -cargo tauri build -``` - -**Result**: Tauri desktop app with SQLite persistence via rusqlite - -### Web Build - -```bash -# Build frontend to WASM -cd apps/papillon/frontend -trunk build --release - -# Output in: dist/ -# Deployment: GitHub Pages or custom domain -``` - -**Result**: WASM app (~5MB) that can be served via HTTP - -### Verification Checks - -```bash -# Verify desktop builds (should pass) -cargo check --package papillon - -# Verify web builds (should pass) -cd apps/papillon/frontend -cargo build --target wasm32-unknown-unknown --lib - -# Verify feature isolation -cargo build --target wasm32-unknown-unknown -p papillon-shared \ - --no-default-features --features wasm --lib -``` - -## CI/CD Pipeline - -### New Workflow: `.github/workflows/web-build.yml` - -**Triggers**: Push to main, PRs to main - -**Jobs**: - -1. **web-build** (~3 min) - - Install `wasm32-unknown-unknown` target - - Install trunk - - Build with `trunk build --release` - - Upload `dist/` artifact (7-day retention) - -2. **web-check** (~1 min) - - Quick WASM target compilation check - - Catches breaking changes early - -3. **web-test** (~1 min) - - Verifies HTTP server can serve built app - - Basic health check - -**Total CI time**: ~5 minutes per PR (independent of existing checks) - -## Backward Compatibility - -✅ **Zero breaking changes to desktop**: -- `crate::db::Database` type is still available -- All original methods work identically -- Error handling unchanged (via PapillonError) -- Tests migrate directly from old db.rs -- Profiles database (profiles_db.rs) unaffected - -✅ **Frontend unchanged**: -- Leptos CSR compilation unchanged -- Tauri bridge gracefully errors in web mode -- No UI code modifications needed - -## Next Phase: SQL.js Integration (Future) - -The WASM database implementation is a stub ready for full sql.js integration: - -```rust -// crates/papillon-shared/Cargo.toml - Add: -sql-js = "0.1" # JavaScript SQLite -idb = "0.4" # IndexedDB wrapper - -// apps/papillon/frontend/Cargo.toml - Add: -wasm-bindgen-futures = "0.4" -``` - -**Implementation tasks**: -1. Initialize sql.js module in `WasmDatabase::new()` -2. Implement schema initialization -3. Add IndexedDB persistence wrapper -4. Implement all 9 DatabaseOps methods -5. Test browser persistence across page reloads - -**Estimated effort**: 2-4 hours for full implementation - -## Testing - -### Local Testing - -```bash -# Test both builds locally -cargo check --package papillon # Desktop -cd apps/papillon/frontend && \ - cargo build --target wasm32-unknown-unknown --lib # Web - -# Run Tauri desktop -cd apps/papillon && cargo tauri dev - -# Run web locally -cd apps/papillon/frontend && trunk serve # Serves at http://localhost:8080 -``` - -### CI Testing - -- Native path tested by existing `cargo test --workspace` -- Web path tested by new `web-build.yml` -- Both paths run on every PR - -## Deployment - -### Web Deployment Options - -1. **GitHub Pages** (free, auto-deploy from dist/) - ```bash - # After building: dist/ → GitHub Pages - # Access at: github.com/user/repo/papillon/ - ``` - -2. **Custom Domain** (e.g., papillon.example.com) - - Host static `dist/` files - - No backend required (except for future API calls) - -3. **Vercel/Netlify** (free tier available) - ```bash - # One-click deploy from GitHub - # Auto-rebuild on push - ``` - -## Design Principles Applied - -This implementation demonstrates **SOLID principles**: - -- **Single Responsibility**: Database layer has one job (abstract storage) -- **Open/Closed**: Open for extension (sql.js), closed for modification (existing code) -- **Liskov Substitution**: `NativeDatabase` and `WasmDatabase` both satisfy `DatabaseOps` -- **Interface Segregation**: `DatabaseOps` trait has focused, minimal interface -- **Dependency Inversion**: Code depends on `DatabaseOps` trait, not concrete types - -## Key Metrics - -| Metric | Value | -|--------|-------| -| Lines of code added | ~700 | -| Lines of code removed | ~1100 (old db.rs refactored into shared) | -| Net change | -400 LOC (better structured) | -| Build time (desktop) | No change (~30 sec) | -| Build time (web) | ~2 min (Trunk + WASM compile) | -| CI time per PR | +5 min (new web-build.yml) | -| Breaking changes | 0 | - -## Verification Checklist - -- ✅ Desktop `cargo tauri build` compiles without errors -- ✅ Web `trunk build` compiles without errors -- ✅ WASM target `cargo build --target wasm32-unknown-unknown` compiles without errors -- ✅ Feature flags correctly isolate rusqlite (desktop only) -- ✅ Error conversions work for all database method calls -- ✅ All trait methods have consistent signatures -- ✅ CI workflow runs and produces artifacts -- ✅ Backward compatibility maintained (no breaking changes) -- ✅ Type safety: compile-time database backend selection - -## Conclusion - -The Tauri web build infrastructure is now in place and ready for production use. The modular database abstraction allows: - -- **Desktop**: Unchanged behavior with persistent SQLite -- **Web**: Stub implementation ready for sql.js integration -- **CI**: Automatic web build validation on every PR -- **Maintenance**: Single codebase for both targets - -No sql.js implementation is needed for the MVP. The framework supports adding it incrementally when required. diff --git a/apps/papillon/frontend/Cargo.lock b/apps/papillon/frontend/Cargo.lock index 9ce0e26a..f5fcd177 100644 --- a/apps/papillon/frontend/Cargo.lock +++ b/apps/papillon/frontend/Cargo.lock @@ -1249,7 +1249,7 @@ checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" [[package]] name = "pap-core" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64", "chrono", @@ -1267,7 +1267,7 @@ dependencies = [ [[package]] name = "pap-credential" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64", "chrono", @@ -1285,7 +1285,7 @@ dependencies = [ [[package]] name = "pap-did" -version = "0.8.2" +version = "0.8.3" dependencies = [ "bs58", "ed25519-dalek", @@ -1298,7 +1298,7 @@ dependencies = [ [[package]] name = "pap-federation" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64", "chrono", @@ -1317,7 +1317,7 @@ dependencies = [ [[package]] name = "pap-marketplace" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64", "chrono", @@ -1334,7 +1334,7 @@ dependencies = [ [[package]] name = "pap-proto" -version = "0.8.2" +version = "0.8.3" dependencies = [ "aes-gcm", "base64", @@ -1356,7 +1356,7 @@ dependencies = [ [[package]] name = "papillon-shared" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", diff --git a/apps/papillon/frontend/docs/superpowers/plans/2026-05-14-block-based-canvas-architecture.md b/apps/papillon/frontend/docs/superpowers/plans/2026-05-14-block-based-canvas-architecture.md new file mode 100644 index 00000000..42bc3bc5 --- /dev/null +++ b/apps/papillon/frontend/docs/superpowers/plans/2026-05-14-block-based-canvas-architecture.md @@ -0,0 +1,1271 @@ +# Block-Based Canvas Architecture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Transform Papillon canvas from single-agent blocks to multi-agent schema.org containers with node-graph wiring, enabling visual pipeline composition where blocks represent multiple agents with matching I/O signatures. + +**Architecture:** Blocks become containers for N agents sharing the same schema.org signature (input/output types). BM25 intent detection matches prompts to schema types. Blocks can wire together when output schema matches input schema, creating visual pipelines. No LLM required for core functionality - all type matching is deterministic via schema.org vocabulary. + +**Tech Stack:** Rust (Leptos frontend, Tauri backend), schema.org vocabulary, BM25 text search, existing PAP protocol infrastructure + +--- + +## Architecture Overview + +### Current State +- **CanvasBlock** = single agent execution result +- Prompt → intent detection → single agent → rendered block +- Agent curation happens in workflow panel (separate from block) +- Blocks are independent (no connections) + +### Target State +- **CanvasBlock** = container for multiple agents with same schema signature +- Block represents a **schema.org type transformation** (e.g., Place → WeatherForecast) +- Multiple agents can fulfill same transformation (OpenWeather, NOAA, WeatherAPI all provide Place → WeatherForecast) +- Blocks can **wire together** when output type matches input type +- Visual node graph shows data flow through agent pipeline +- Marketplace search finds agents by schema signature + +### Key Concepts + +**Schema Signature:** +- **Input types**: e.g., `[schema:Place]` +- **Output types**: e.g., `[schema:WeatherForecast]` +- **Agents match**: All agents with signature `Place → WeatherForecast` can fill same block + +**Block Wiring:** +- Output port: Block produces `schema:WeatherForecast` +- Input port: Block accepts `schema:Place, schema:WeatherForecast` +- **Connection valid** if output type ⊆ input types +- **Disclosure check**: Connection cannot increase disclosure beyond what user approved + +**Agent Curation:** +- Happens **per-block** (not global workflow panel) +- User selects which agents to execute from those matching signature +- "Find more" searches marketplace for agents with matching signature + +--- + +## File Structure + +### New Files + +**Frontend Components:** +- `apps/papillon/frontend/src/components/block_container.rs` - Block as multi-agent container +- `apps/papillon/frontend/src/components/block_ports.rs` - Input/output port UI +- `apps/papillon/frontend/src/components/block_wiring.rs` - Visual connections between blocks +- `apps/papillon/frontend/src/components/agent_selector.rs` - Per-block agent selection +- `apps/papillon/frontend/src/components/canvas_graph.rs` - Node graph layout engine + +**Backend Types:** +- `crates/papillon-shared/src/block_container.rs` - BlockContainer type +- `crates/papillon-shared/src/schema_signature.rs` - Schema signature matching +- `crates/papillon-shared/src/block_connection.rs` - Block wiring types + +**Backend Commands:** +- `apps/papillon/src/commands/canvas/block_wiring.rs` - Connect/disconnect blocks +- `apps/papillon/src/commands/canvas/agent_search.rs` - Find agents by schema + +### Modified Files + +**Types:** +- `crates/papillon-shared/src/types.rs` - Extend CanvasBlock with container fields +- `crates/papillon-shared/src/types.rs` - Add BlockPort, BlockConnection types + +**Frontend:** +- `apps/papillon/frontend/src/pages/canvas.rs` - Integrate graph view +- `apps/papillon/frontend/src/components/block_renderer.rs` - Support block containers +- `apps/papillon/frontend/styles/main.css` - Block container, port, wire styles + +**Backend:** +- `apps/papillon/src/commands/canvas/approval.rs` - Handle multi-agent approval +- `apps/papillon/src/commands/canvas/execution.rs` - Execute multiple agents per block + +--- + +## Task 1: Schema Signature Types + +**Files:** +- Create: `crates/papillon-shared/src/schema_signature.rs` +- Modify: `crates/papillon-shared/src/lib.rs` + +- [ ] **Step 1: Create schema signature module** + +Create `crates/papillon-shared/src/schema_signature.rs`: + +```rust +use serde::{Deserialize, Serialize}; + +/// Schema.org signature defining what types a block accepts and produces. +/// +/// Example: Weather block accepts Place, produces WeatherForecast +/// Signature: inputs=[schema:Place], outputs=[schema:WeatherForecast] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SchemaSignature { + /// Schema.org types this block accepts as input (empty = no inputs) + pub input_types: Vec, + /// Schema.org types this block produces as output + pub output_types: Vec, +} + +impl SchemaSignature { + /// Create signature from agent metadata + pub fn from_agent(requires: &[String], returns: &[String]) -> Self { + Self { + input_types: requires.to_vec(), + output_types: returns.to_vec(), + } + } + + /// Check if this signature's outputs can connect to another's inputs + pub fn can_wire_to(&self, other: &SchemaSignature) -> bool { + if other.input_types.is_empty() { + return false; // Target accepts no inputs + } + + // At least one output type must match at least one input type + self.output_types.iter().any(|out| { + other.input_types.iter().any(|inp| out == inp) + }) + } + + /// Check if two signatures are compatible (same I/O types) + pub fn matches(&self, other: &SchemaSignature) -> bool { + self.input_types == other.input_types && + self.output_types == other.output_types + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_can_wire_place_to_weather() { + let place_block = SchemaSignature { + input_types: vec![], + output_types: vec!["schema:Place".to_string()], + }; + + let weather_block = SchemaSignature { + input_types: vec!["schema:Place".to_string()], + output_types: vec!["schema:WeatherForecast".to_string()], + }; + + assert!(place_block.can_wire_to(&weather_block)); + assert!(!weather_block.can_wire_to(&place_block)); + } + + #[test] + fn test_matches_same_signature() { + let sig1 = SchemaSignature { + input_types: vec!["schema:Place".to_string()], + output_types: vec!["schema:WeatherForecast".to_string()], + }; + + let sig2 = sig1.clone(); + assert!(sig1.matches(&sig2)); + } +} +``` + +- [ ] **Step 2: Export in lib.rs** + +Add to `crates/papillon-shared/src/lib.rs`: + +```rust +pub mod schema_signature; +pub use schema_signature::SchemaSignature; +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cargo check -p papillon-shared` +Expected: No errors + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p papillon-shared schema_signature` +Expected: 2 tests pass + +- [ ] **Step 5: Commit** + +```bash +git add crates/papillon-shared/src/schema_signature.rs crates/papillon-shared/src/lib.rs +git commit -m "feat(types): add SchemaSignature for block I/O matching + +- Define SchemaSignature with input/output schema.org types +- Implement can_wire_to() for connection validation +- Implement matches() for agent compatibility +- Add unit tests for wiring logic" +``` + +--- + +## Task 2: Block Container Types + +**Files:** +- Create: `crates/papillon-shared/src/block_container.rs` +- Modify: `crates/papillon-shared/src/lib.rs` +- Modify: `crates/papillon-shared/src/types.rs` + +- [ ] **Step 1: Create block container module** + +Create `crates/papillon-shared/src/block_container.rs`: + +```rust +use serde::{Deserialize, Serialize}; +use crate::SchemaSignature; + +/// A block container holding multiple agents with the same schema signature. +/// +/// Represents a single transformation in the canvas graph (e.g., Place → Weather). +/// Multiple agents can provide this transformation - user selects which to execute. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockContainer { + /// Unique container ID + pub id: String, + /// Schema signature (what this block accepts/produces) + pub signature: SchemaSignature, + /// Agents that can fulfill this signature (DIDs) + pub candidate_agents: Vec, + /// Which agents user selected to execute + pub selected_agents: Vec, + /// Connections to other blocks (by block ID) + pub input_connections: Vec, + /// Visual position on canvas (for node graph layout) + pub position: BlockPosition, +} + +/// Connection from one block's output to another's input +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockConnection { + /// Source block ID + pub from_block_id: String, + /// Target block ID (this block) + pub to_block_id: String, + /// Which output type from source + pub output_type: String, + /// Which input type on target + pub input_type: String, +} + +/// 2D position for node graph layout +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockPosition { + pub x: f64, + pub y: f64, +} + +impl Default for BlockPosition { + fn default() -> Self { + Self { x: 0.0, y: 0.0 } + } +} +``` + +- [ ] **Step 2: Export in lib.rs** + +Add to `crates/papillon-shared/src/lib.rs`: + +```rust +pub mod block_container; +pub use block_container::{BlockContainer, BlockConnection, BlockPosition}; +``` + +- [ ] **Step 3: Extend CanvasBlock in types.rs** + +Add fields to `pub struct CanvasBlock` (around line 716): + +```rust +/// If this block is a container, holds the container metadata +#[serde(default)] +#[serde(skip_serializing_if = "Option::is_none")] +pub container: Option, +``` + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check -p papillon-shared` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add crates/papillon-shared/src/block_container.rs crates/papillon-shared/src/lib.rs crates/papillon-shared/src/types.rs +git commit -m "feat(types): add BlockContainer for multi-agent blocks + +- Define BlockContainer with schema signature and agent list +- Add BlockConnection for wiring blocks together +- Add BlockPosition for node graph layout +- Extend CanvasBlock with optional container field" +``` + +--- + +## Task 3: Block Port Component + +**Files:** +- Create: `apps/papillon/frontend/src/components/block_ports.rs` +- Modify: `apps/papillon/frontend/src/components/mod.rs` + +- [ ] **Step 1: Create block ports component** + +Create `apps/papillon/frontend/src/components/block_ports.rs`: + +```rust +use leptos::prelude::*; +use papillon_shared::{SchemaSignature, BlockConnection}; + +/// Input port showing what schema types this block accepts +#[component] +pub fn BlockInputPort( + block_id: String, + signature: SchemaSignature, + connections: Vec, +) -> impl IntoView { + let has_connections = !connections.is_empty(); + let input_label = if signature.input_types.len() == 1 { + humanize_schema_type(&signature.input_types[0]) + } else { + format!("{} types", signature.input_types.len()) + }; + + view! { +
+
+
{input_label}
+
+ } +} + +/// Output port showing what schema types this block produces +#[component] +pub fn BlockOutputPort( + block_id: String, + signature: SchemaSignature, +) -> impl IntoView { + let output_label = if signature.output_types.len() == 1 { + humanize_schema_type(&signature.output_types[0]) + } else { + format!("{} types", signature.output_types.len()) + }; + + view! { +
+
{output_label}
+
+
+ } +} + +fn humanize_schema_type(schema_type: &str) -> String { + schema_type + .strip_prefix("schema:") + .unwrap_or(schema_type) + .to_string() +} +``` + +- [ ] **Step 2: Export component** + +Add to `apps/papillon/frontend/src/components/mod.rs`: + +```rust +pub mod block_ports; +``` + +- [ ] **Step 3: Add port styles to CSS** + +Append to `apps/papillon/frontend/styles/main.css`: + +```css +/* ═══════════════════════════════════════════════════════════ + BLOCK PORTS + ═══════════════════════════════════════════════════════════ */ + +.block-port { + display: flex; + align-items: center; + gap: var(--sp-sm); + padding: var(--sp-xs) var(--sp-sm); + font: var(--text-small); + color: var(--text-secondary); +} + +.block-input-port { + justify-content: flex-start; +} + +.block-output-port { + justify-content: flex-end; +} + +.port-dot { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid var(--border); + background: var(--bg-secondary); + transition: all 150ms ease; + cursor: pointer; +} + +.port-dot:hover { + border-color: var(--purple); + transform: scale(1.2); +} + +.block-input-port.connected .port-dot { + background: var(--teal); + border-color: var(--teal); +} + +.port-label { + font-weight: 500; + text-transform: capitalize; +} +``` + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check -p papillon-frontend` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add apps/papillon/frontend/src/components/block_ports.rs apps/papillon/frontend/src/components/mod.rs apps/papillon/frontend/styles/main.css +git commit -m "feat(ui): add block port components for I/O visualization + +- Create BlockInputPort showing accepted schema types +- Create BlockOutputPort showing produced schema types +- Add port dot UI with hover states +- Style ports with teal highlight when connected" +``` + +--- + +## Task 4: Agent Selector Component + +**Files:** +- Create: `apps/papillon/frontend/src/components/agent_selector.rs` +- Modify: `apps/papillon/frontend/src/components/mod.rs` + +- [ ] **Step 1: Create agent selector component** + +Create `apps/papillon/frontend/src/components/agent_selector.rs`: + +```rust +use leptos::prelude::*; +use papillon_shared::AgentCandidate; + +/// Per-block agent selection UI +/// Shows all agents matching block's schema signature +#[component] +pub fn AgentSelector( + block_id: String, + candidates: Vec, + selected: RwSignal>, +) -> impl IntoView { + let agent_count = candidates.len(); + + view! { +
+
+ {agent_count}" agents available" + +
+ +
+ + } + } + /> +
+
+ } +} + +#[component] +fn AgentOption( + agent: AgentCandidate, + selected: RwSignal>, +) -> impl IntoView { + let agent_did = agent.did.clone(); + let agent_did_for_toggle = agent.did.clone(); + + let is_selected = Memo::new(move |_| { + selected.get().contains(&agent_did) + }); + + let is_on_device = agent.did.starts_with("did:key:"); + let truncated_did = truncate_did(&agent.did); + + view! { + + } +} + +fn truncate_did(did: &str) -> String { + if did.len() <= 30 { + return did.to_string(); + } + format!("{}...{}", &did[..20], &did[did.len() - 8..]) +} +``` + +- [ ] **Step 2: Export component** + +Add to `apps/papillon/frontend/src/components/mod.rs`: + +```rust +pub mod agent_selector; +``` + +- [ ] **Step 3: Add agent selector styles** + +Append to `apps/papillon/frontend/styles/main.css`: + +```css +/* ═══════════════════════════════════════════════════════════ + AGENT SELECTOR (PER-BLOCK) + ═══════════════════════════════════════════════════════════ */ + +.agent-selector { + padding: var(--sp-md); + background: var(--bg-tertiary); + border-radius: var(--r-md); +} + +.agent-selector-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--sp-md); +} + +.agent-count { + font: var(--text-small); + color: var(--text-secondary); + font-weight: 500; +} + +.find-more-agents-btn { + padding: var(--sp-xs) var(--sp-sm); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--r-sm); + cursor: pointer; + font: var(--text-small); + color: var(--text-primary); + transition: all 150ms ease; +} + +.find-more-agents-btn:hover { + background: var(--purple-muted); + border-color: var(--purple); +} + +.agent-selector-list { + display: flex; + flex-direction: column; + gap: var(--sp-sm); +} + +.agent-option { + display: flex; + align-items: flex-start; + gap: var(--sp-md); + padding: var(--sp-md); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--r-md); + cursor: pointer; + transition: all 150ms ease; +} + +.agent-option:hover { + background: var(--surface-card-hover); + border-color: var(--surface-card-border-hover); +} + +.agent-option.selected { + background: var(--purple-muted); + border-color: var(--purple); +} + +.agent-checkbox { + margin-top: 2px; + flex-shrink: 0; +} + +.agent-option-info { + flex: 1; +} + +.agent-option-header { + display: flex; + align-items: center; + gap: var(--sp-sm); + margin-bottom: var(--sp-xs); +} + +.agent-name { + font: var(--text-ui); + font-weight: 700; + color: var(--text-primary); +} + +.agent-did { + display: block; + font: var(--text-mono); + font-size: 11px; + color: var(--text-secondary); +} +``` + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check -p papillon-frontend` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add apps/papillon/frontend/src/components/agent_selector.rs apps/papillon/frontend/src/components/mod.rs apps/papillon/frontend/styles/main.css +git commit -m "feat(ui): add per-block agent selector component + +- Create AgentSelector showing all agents for block signature +- Multi-select checkboxes for agent selection +- Find more button for marketplace search +- On-device trust badges +- DID truncation for readability" +``` + +--- + +## Task 5: Block Container Component + +**Files:** +- Create: `apps/papillon/frontend/src/components/block_container.rs` +- Modify: `apps/papillon/frontend/src/components/mod.rs` + +- [ ] **Step 1: Create block container component** + +Create `apps/papillon/frontend/src/components/block_container.rs`: + +```rust +use leptos::prelude::*; +use papillon_shared::{CanvasBlock, BlockContainer}; + +use crate::components::agent_selector::AgentSelector; +use crate::components::block_ports::{BlockInputPort, BlockOutputPort}; +use crate::components::block_renderer::BlockRenderer; + +/// Block as multi-agent container with I/O ports +#[component] +pub fn BlockContainerView(block: CanvasBlock) -> impl IntoView { + let container = match &block.container { + Some(c) => c.clone(), + None => { + // Fallback to single-block rendering if not a container + return view! { + + }.into_any(); + } + }; + + let selected_agents = RwSignal::new(container.selected_agents.clone()); + let show_selector = RwSignal::new(false); + + let toggle_selector = move |_| { + show_selector.update(|v| *v = !*v); + }; + + view! { +
+ // Input port (if block accepts inputs) + + + + + // Block header with agent selector toggle +
+

+ {humanize_signature(&container.signature)} +

+ +
+ + // Agent selector (expandable) + + + + + // Rendered content +
+ +
+ + // Output port + +
+ }.into_any() +} + +fn humanize_signature(sig: &papillon_shared::SchemaSignature) -> String { + let output = if sig.output_types.len() == 1 { + sig.output_types[0].strip_prefix("schema:").unwrap_or(&sig.output_types[0]).to_string() + } else { + format!("{} types", sig.output_types.len()) + }; + + if sig.input_types.is_empty() { + output + } else { + let input = if sig.input_types.len() == 1 { + sig.input_types[0].strip_prefix("schema:").unwrap_or(&sig.input_types[0]).to_string() + } else { + format!("{} types", sig.input_types.len()) + }; + format!("{} → {}", input, output) + } +} +``` + +- [ ] **Step 2: Export component** + +Add to `apps/papillon/frontend/src/components/mod.rs`: + +```rust +pub mod block_container; +``` + +- [ ] **Step 3: Add block container styles** + +Append to `apps/papillon/frontend/styles/main.css`: + +```css +/* ═══════════════════════════════════════════════════════════ + BLOCK CONTAINER + ═══════════════════════════════════════════════════════════ */ + +.block-container { + position: relative; + margin: var(--sp-lg) 0; + padding: var(--sp-lg); + background: var(--bg-secondary); + border: 2px solid var(--border); + border-radius: var(--r-lg); + transition: all 150ms ease; +} + +.block-container:hover { + border-color: var(--purple-muted); + box-shadow: 0 2px 8px rgba(108, 92, 231, 0.1); +} + +.block-container-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--sp-md); + padding-bottom: var(--sp-md); + border-bottom: 1px solid var(--border-subtle); +} + +.block-container-title { + margin: 0; + font: var(--text-h3); + color: var(--text-primary); +} + +.agent-selector-toggle { + padding: var(--sp-xs) var(--sp-md); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--r-md); + cursor: pointer; + font: var(--text-ui); + font-weight: 500; + color: var(--text-secondary); + transition: all 150ms ease; +} + +.agent-selector-toggle:hover { + background: var(--purple-muted); + border-color: var(--purple); + color: var(--text-primary); +} + +.block-container-content { + margin: var(--sp-md) 0; +} +``` + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check -p papillon-frontend` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add apps/papillon/frontend/src/components/block_container.rs apps/papillon/frontend/src/components/mod.rs apps/papillon/frontend/styles/main.css +git commit -m "feat(ui): add block container component with ports + +- Create BlockContainerView wrapping blocks with I/O ports +- Show input/output ports based on signature +- Expandable agent selector per block +- Humanize signature display (e.g., Place → Weather) +- Fallback to single BlockRenderer if not container" +``` + +--- + +## Task 6: Integration - Use Block Containers in Canvas + +**Files:** +- Modify: `apps/papillon/frontend/src/pages/canvas.rs` + +- [ ] **Step 1: Import BlockContainerView** + +Add to imports in `apps/papillon/frontend/src/pages/canvas.rs`: + +```rust +use crate::components::block_container::BlockContainerView; +``` + +- [ ] **Step 2: Replace BlockRenderer with BlockContainerView** + +Find the `For` loop rendering blocks (around line 149) and replace: + +```rust +// OLD: + + +// NEW: + +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cargo check -p papillon-frontend` +Expected: No errors + +- [ ] **Step 4: Test in browser** + +Run: `cargo tauri dev` +Expected: Blocks render with BlockContainerView (will show fallback to BlockRenderer since containers not yet created) + +- [ ] **Step 5: Commit** + +```bash +git add apps/papillon/frontend/src/pages/canvas.rs +git commit -m "feat(ui): integrate block containers into canvas rendering + +- Replace BlockRenderer with BlockContainerView +- Blocks now render with container UI when available +- Falls back to original BlockRenderer for non-containers" +``` + +--- + +## Task 7: Backend Command - Create Block Container + +**Files:** +- Create: `apps/papillon/src/commands/canvas/container.rs` +- Modify: `apps/papillon/src/commands/canvas/mod.rs` + +- [ ] **Step 1: Create container command** + +Create `apps/papillon/src/commands/canvas/container.rs`: + +```rust +use tauri::{AppHandle, State}; +use papillon_shared::{BlockContainer, SchemaSignature, BlockPosition}; + +use crate::state::AppState; + +/// Create a block container from a prompt +/// +/// Takes intent classification result and creates a container with +/// all agents matching the schema signature +#[tauri::command] +pub async fn create_block_container( + app: AppHandle, + state: State<'_, AppState>, + canvas_id: String, + action_type: String, + query: String, +) -> Result { + // Get agents matching this action type + let agents = state.db.load_all_agents() + .map_err(|e| format!("Failed to load agents: {}", e))?; + + let matching_agents: Vec<_> = agents.iter() + .filter(|a| a.action_types.contains(&action_type)) + .collect(); + + if matching_agents.is_empty() { + return Err(format!("No agents found for action: {}", action_type)); + } + + // Determine signature from first agent (all should match) + let first_agent = matching_agents[0]; + let signature = SchemaSignature { + input_types: first_agent.requires_disclosure.clone(), + output_types: first_agent.returns.clone(), + }; + + // Create container + let container_id = uuid::Uuid::new_v4().to_string(); + let container = BlockContainer { + id: container_id.clone(), + signature, + candidate_agents: matching_agents.iter().map(|a| a.did.clone()).collect(), + selected_agents: vec![first_agent.did.clone()], // Default to first + input_connections: vec![], + position: BlockPosition::default(), + }; + + // Create canvas block with container + let block_id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + let block = papillon_shared::CanvasBlock { + id: block_id.clone(), + prompt_id: uuid::Uuid::new_v4().to_string(), + prompt_text: Some(query), + state: papillon_shared::BlockState::Resolving { + phase: 0, + phase_label: "Creating container...".to_string(), + }, + schema_type: None, + content: None, + linked_block_ids: vec![], + agent_did: None, + created_at: now.clone(), + updated_at: now, + mandate_expires_at: None, + preference_guided: false, + auto_expand: false, + retention_warning: None, + container: Some(container), + }; + + // Add to canvas (TODO: implement canvas storage) + + Ok(block_id) +} +``` + +- [ ] **Step 2: Export command** + +Add to `apps/papillon/src/commands/canvas/mod.rs`: + +```rust +pub mod container; +pub use container::create_block_container; +``` + +- [ ] **Step 3: Register Tauri command** + +Add to Tauri builder in `apps/papillon/src/main.rs`: + +```rust +.invoke_handler(tauri::generate_handler![ + // ... existing commands ... + commands::canvas::create_block_container, +]) +``` + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check -p papillon` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add apps/papillon/src/commands/canvas/container.rs apps/papillon/src/commands/canvas/mod.rs apps/papillon/src/main.rs +git commit -m "feat(backend): add create_block_container command + +- Create BlockContainer from intent classification +- Find all agents matching action type/signature +- Default to first agent selected +- Emit block with container field populated" +``` + +--- + +## Task 8: Wire Blocks Command + +**Files:** +- Create: `apps/papillon/src/commands/canvas/wiring.rs` +- Modify: `apps/papillon/src/commands/canvas/mod.rs` + +- [ ] **Step 1: Create wiring command** + +Create `apps/papillon/src/commands/canvas/wiring.rs`: + +```rust +use tauri::{AppHandle, Emitter, State}; +use papillon_shared::{BlockConnection, BlockEvent, BlockUpdate}; + +use crate::state::AppState; + +/// Connect one block's output to another's input +/// +/// Validates that: +/// 1. Output type matches input type (schema compatibility) +/// 2. Connection doesn't increase disclosure scope +#[tauri::command] +pub async fn connect_blocks( + app: AppHandle, + state: State<'_, AppState>, + canvas_id: String, + from_block_id: String, + to_block_id: String, + output_type: String, + input_type: String, +) -> Result<(), String> { + // Validate types match + if output_type != input_type { + return Err(format!( + "Type mismatch: output {} != input {}", + output_type, input_type + )); + } + + // TODO: Validate disclosure scope doesn't increase + + // Create connection + let connection = BlockConnection { + from_block_id: from_block_id.clone(), + to_block_id: to_block_id.clone(), + output_type, + input_type, + }; + + // TODO: Store connection in canvas + + // Emit update event + let now = chrono::Utc::now().to_rfc3339(); + let _ = app.emit( + "block_connected", + BlockEvent { + block: BlockUpdate { + id: to_block_id, + prompt_id: String::new(), + prompt_text: None, + state: papillon_shared::BlockState::Resolved, + schema_type: None, + content: None, + agent_did: None, + mandate_expires_at: None, + preference_guided: false, + created_at: now.clone(), + updated_at: now, + retention_warning: None, + }, + }, + ); + + Ok(()) +} + +/// Disconnect blocks +#[tauri::command] +pub async fn disconnect_blocks( + app: AppHandle, + state: State<'_, AppState>, + canvas_id: String, + from_block_id: String, + to_block_id: String, +) -> Result<(), String> { + // TODO: Remove connection from canvas + + let now = chrono::Utc::now().to_rfc3339(); + let _ = app.emit( + "block_disconnected", + BlockEvent { + block: BlockUpdate { + id: to_block_id, + prompt_id: String::new(), + prompt_text: None, + state: papillon_shared::BlockState::Resolved, + schema_type: None, + content: None, + agent_did: None, + mandate_expires_at: None, + preference_guided: false, + created_at: now.clone(), + updated_at: now, + retention_warning: None, + }, + }, + ); + + Ok(()) +} +``` + +- [ ] **Step 2: Export commands** + +Add to `apps/papillon/src/commands/canvas/mod.rs`: + +```rust +pub mod wiring; +pub use wiring::{connect_blocks, disconnect_blocks}; +``` + +- [ ] **Step 3: Register commands** + +Add to Tauri builder in `apps/papillon/src/main.rs`: + +```rust +.invoke_handler(tauri::generate_handler![ + // ... existing commands ... + commands::canvas::connect_blocks, + commands::canvas::disconnect_blocks, +]) +``` + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check -p papillon` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add apps/papillon/src/commands/canvas/wiring.rs apps/papillon/src/commands/canvas/mod.rs apps/papillon/src/main.rs +git commit -m "feat(backend): add block wiring commands + +- Implement connect_blocks with schema validation +- Implement disconnect_blocks +- Emit block_connected/block_disconnected events +- TODO: Add disclosure scope validation" +``` + +--- + +## Post-Implementation Tasks + +### Testing Checklist + +- [ ] Create block with prompt +- [ ] Verify block shows as container with ports +- [ ] Expand agent selector, see multiple agents +- [ ] Select different agents +- [ ] Create second block that accepts first block's output type +- [ ] Drag wire from output port to input port +- [ ] Verify connection appears +- [ ] Disconnect blocks +- [ ] Test "Find more" marketplace search (stub for now) + +### Known Limitations + +1. **Canvas storage**: Block containers not yet persisted to DB +2. **Disclosure validation**: Connection disclosure checks not implemented +3. **Marketplace search**: "Find more" button is stub +4. **Visual wiring**: Drag-and-drop wire creation not implemented +5. **Graph layout**: Auto-layout algorithm not implemented +6. **Multi-agent execution**: Only first agent executes currently + +### Future Enhancements (Post v1.0) + +- Block wiring via drag-and-drop +- Visual wire rendering (SVG/Canvas) +- Auto-layout algorithm (dagre, elk) +- Marketplace search by schema signature +- Multi-agent parallel execution +- Result merging/synthesis +- Block templates/presets +- Graph zoom/pan +- Minimap navigation +- Undo/redo for wiring +- Export graph as workflow + +--- + +## Spec Alignment Check + +**Covered:** +- ✓ Schema signature types (Task 1) +- ✓ Block container types (Task 2) +- ✓ Block ports UI (Task 3) +- ✓ Per-block agent selector (Task 4) +- ✓ Block container component (Task 5) +- ✓ Canvas integration (Task 6) +- ✓ Backend container creation (Task 7) +- ✓ Block wiring commands (Task 8) + +**Not Covered (Deferred):** +- Visual wire rendering (SVG between ports) +- Drag-and-drop wiring interaction +- Marketplace search implementation +- Graph auto-layout algorithm +- Multi-agent parallel execution +- Canvas persistence layer updates + +All Must Have features for block-based architecture foundation are implemented. +Core types, UI components, and backend commands ready for visual wiring phase. diff --git a/apps/papillon/frontend/src/app.rs b/apps/papillon/frontend/src/app.rs index 69f8aa5a..b813dd17 100644 --- a/apps/papillon/frontend/src/app.rs +++ b/apps/papillon/frontend/src/app.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use crate::bridge; use crate::components::setup_wizard::SetupWizard; -use crate::components::topbar::TopBar; +// TopBar removed - tab bar is now the primary navigation use crate::orchestrator_runtime::fallback_status_for_config; use crate::pages::activity::ActivityPage; use crate::pages::browse::BrowsePage; @@ -422,7 +422,7 @@ pub fn App() -> impl IntoView { view! {
- + // TopBar removed - tab bar is now the primary navigation
diff --git a/apps/papillon/frontend/src/commands/approval.rs b/apps/papillon/frontend/src/commands/approval.rs new file mode 100644 index 00000000..a4793865 --- /dev/null +++ b/apps/papillon/frontend/src/commands/approval.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Signed challenge for identity authorization. +/// Must match the backend SignedChallenge structure. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SignedChallenge { + pub challenge_id: String, + pub signature_b64: String, +} + +/// Payload for the approval command. +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalPayload { + pub approval_request_id: String, + pub approved: bool, + pub signed_challenge: SignedChallenge, + pub filled_values: Option>, + pub selected_agent_names: Option>, +} + +/// Call the backend approval command. +/// +/// In desktop builds (Tauri), this invokes the `canvas_approve_block` command. +/// In WASM builds, this would call through the bridge (currently returning an error). +pub async fn approve_intent_plan(payload: ApprovalPayload) -> Result<(), String> { + #[cfg(target_family = "wasm")] + { + crate::bridge::invoke("canvas_approve_block", &payload).await + } + + #[cfg(not(target_family = "wasm"))] + { + let _ = payload; // Silence unused warning + Err("approve_intent_plan is only available in Tauri builds".to_string()) + } +} diff --git a/apps/papillon/frontend/src/commands/mod.rs b/apps/papillon/frontend/src/commands/mod.rs new file mode 100644 index 00000000..8de4ee35 --- /dev/null +++ b/apps/papillon/frontend/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod approval; diff --git a/apps/papillon/frontend/src/components/agent_curation_list.rs b/apps/papillon/frontend/src/components/agent_curation_list.rs new file mode 100644 index 00000000..efc52300 --- /dev/null +++ b/apps/papillon/frontend/src/components/agent_curation_list.rs @@ -0,0 +1,102 @@ +use leptos::prelude::*; +use papillon_shared::{AgentCandidate, IntentPlan}; + +#[component] +pub fn AgentCurationList( + plan: IntentPlan, + selected_agents: RwSignal>, +) -> impl IntoView { + // Initialize selected agents from plan if not already set + let initial_selected = plan.candidates + .iter() + .filter(|c| Some(&c.did) == plan.selected_agent_did.as_ref()) + .map(|c| c.did.clone()) + .collect::>(); + + if selected_agents.get_untracked().is_empty() && !initial_selected.is_empty() { + selected_agents.set(initial_selected); + } + + view! { +
+ + } + } + /> +
+ } +} + +#[component] +fn AgentCurationCard( + agent: AgentCandidate, + selected: ReadSignal>, + on_toggle: RwSignal>, +) -> impl IntoView { + let agent_did = agent.did.clone(); + let agent_did_for_toggle = agent.did.clone(); + + let is_selected = Memo::new(move |_| selected.get().contains(&agent_did)); + + // On-device detection (simplified: check if did:key) + let is_on_device = agent.did.starts_with("did:key:"); + + // Truncate DID for display + let truncated_did = truncate_did(&agent.did); + + view! { +
+ +
+ } +} + +fn truncate_did(did: &str) -> String { + if did.len() <= 30 { + return did.to_string(); + } + format!("{}...{}", &did[..20], &did[did.len() - 8..]) +} diff --git a/apps/papillon/frontend/src/components/agent_selector.rs b/apps/papillon/frontend/src/components/agent_selector.rs new file mode 100644 index 00000000..897b6474 --- /dev/null +++ b/apps/papillon/frontend/src/components/agent_selector.rs @@ -0,0 +1,55 @@ +use leptos::prelude::*; +use papillon_shared::AgentInfo; + +/// Multi-select agent picker for a block container. +/// Shows only agents matching the container's SchemaSignature. +#[component] +pub fn AgentSelector( + /// List of compatible agents (filtered by signature) + agents: Vec, + /// Currently selected agent names + selected: RwSignal>, +) -> impl IntoView { + let toggle_agent = move |name: String| { + selected.update(|sel| { + if sel.contains(&name) { + sel.retain(|n| n != &name); + } else { + sel.push(name); + } + }); + }; + + view! { +
+
"Select Agents"
+
+ +
+ {move || if selected.get().contains(&agent_name_for_check) { "✓" } else { "" }} +
+
+
{agent.name.clone()}
+
{agent.provider_name.clone()}
+
+ + } + } + /> +
+
+ } +} diff --git a/apps/papillon/frontend/src/components/approval_toast.rs b/apps/papillon/frontend/src/components/approval_toast.rs new file mode 100644 index 00000000..5b773a92 --- /dev/null +++ b/apps/papillon/frontend/src/components/approval_toast.rs @@ -0,0 +1,162 @@ +use leptos::prelude::*; +use crate::state::canvas::CanvasState; + +/// Toast notification stack for approval requests. +/// Fixed bottom-right position, animates in from bottom. +#[component] +pub fn ApprovalToastStack() -> impl IntoView { + let canvas_state = expect_context::(); + let hitl_pending = canvas_state.hitl_pending; + + view! { + + {move || { + hitl_pending.get().map(|req| { + view! { + + } + }) + }} + + } +} + +/// Individual approval toast for a single HitL request. +#[component] +fn ApprovalToast(request: crate::state::canvas::HitlRequest) -> impl IntoView { + let canvas_state = expect_context::(); + + // Humanize action type: strip "schema:" prefix and "Action" suffix + let action_display = humanize_action(&request.action_type); + + // Clone fields needed in closures + let agent_name = request.agent_name.clone(); + let disclosure_props_len = request.disclosure_props.len(); + let has_disclosure = !request.disclosure_props.is_empty(); + + // Quick approve handler + let quick_approve = move |_| { + if disclosure_props_len == 0 { + // Zero-disclosure: approve immediately + // TODO: wire to canvas_state.approve_block() in Task 18 + leptos::logging::log!("Quick approve: {}", agent_name); + canvas_state.hitl_pending.set(None); + } else { + // Has disclosure: open workflow panel for review + canvas_state.workflow_panel_open.set(true); + } + }; + + // Dismiss handler + let dismiss = move |_| { + canvas_state.hitl_pending.set(None); + }; + + view! { +
+
+
{request.agent_name.clone()}
+ +
+ +
+
+ {action_display} + {move || { + if request.risk_level == "HIGH" { + view! { "HIGH" }.into_any() + } else if request.risk_level == "CRITICAL" { + view! { "CRITICAL" }.into_any() + } else { + view! { <> }.into_any() + } + }} +
+ +
+ {request.description.clone()} +
+ + {move || { + if !request.disclosure_props.is_empty() { + view! { +
+ "Requires disclosure: " + {request.disclosure_props.join(", ")} +
+ }.into_any() + } else { + view! { <> }.into_any() + } + }} +
+ + +
+ } +} + +/// Strip "schema:" prefix and "Action" suffix from action type strings. +fn humanize_action(action: &str) -> String { + action + .trim_start_matches("schema:") + .trim_end_matches("Action") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn humanize_action_strips_prefix_and_suffix() { + assert_eq!(humanize_action("schema:SearchAction"), "Search"); + assert_eq!(humanize_action("schema:WriteAction"), "Write"); + assert_eq!(humanize_action("schema:ReadAction"), "Read"); + } + + #[test] + fn humanize_action_handles_no_prefix() { + assert_eq!(humanize_action("SearchAction"), "Search"); + } + + #[test] + fn humanize_action_handles_no_suffix() { + assert_eq!(humanize_action("schema:Search"), "schema:Search"); + } + + #[test] + fn humanize_action_handles_plain_string() { + assert_eq!(humanize_action("search"), "search"); + } +} diff --git a/apps/papillon/frontend/src/components/block_container_view.rs b/apps/papillon/frontend/src/components/block_container_view.rs new file mode 100644 index 00000000..8af7a08f --- /dev/null +++ b/apps/papillon/frontend/src/components/block_container_view.rs @@ -0,0 +1,92 @@ +use leptos::prelude::*; +use papillon_shared::BlockContainer; +use crate::components::{BlockInputPort, BlockOutputPort}; + +/// Visual container for a multi-agent block with input/output ports. +#[component] +pub fn BlockContainerView( + container: BlockContainer, + /// Reactive signal for selected agents (empty = use container.agent_names) + #[prop(optional)] + selected_agents: Option>>, +) -> impl IntoView { + let selected = selected_agents.unwrap_or_else(|| { + RwSignal::new(container.agent_names.clone()) + }); + + let has_inputs = !container.signature.input_types.is_empty(); + let has_outputs = !container.signature.output_types.is_empty(); + + let input_connected = !container.input_connections.is_empty(); + let output_connected = !container.output_connections.is_empty(); + + // Clone for closures and prepare all data before view! macro + let input_types_for_port = container.signature.input_types.clone(); + let output_types_for_port = container.signature.output_types.clone(); + let input_types_for_badges = container.signature.input_types.clone(); + let output_types_for_badges = container.signature.output_types.clone(); + let position_x = container.position.x; + let position_y = container.position.y; + + // Build type badge views before entering view! macro + let input_type_views: Vec<_> = input_types_for_badges.iter().map(|t| { + let type_name = t.strip_prefix("schema:").unwrap_or(t).to_string(); + view! {
{type_name}
} + }).collect(); + + let output_type_views: Vec<_> = output_types_for_badges.iter().map(|t| { + let type_name = t.strip_prefix("schema:").unwrap_or(t).to_string(); + view! {
{type_name}
} + }).collect(); + + let show_no_input = input_types_for_badges.is_empty(); + let has_input_types = !input_types_for_badges.is_empty(); + + view! { +
+ {has_inputs.then(|| view! { + + })} + +
+
+ {move || { + let count = selected.get().len(); + if count == 0 { + "No agents selected".to_string() + } else if count == 1 { + selected.get()[0].clone() + } else { + format!("{} agents", count) + } + }} +
+ +
+ {show_no_input.then(|| view! {
"No input"
})} + {has_input_types.then(|| input_type_views.clone())} +
"→"
+ {output_type_views} +
+ +
+ {move || format!("{} agent(s) selected", selected.get().len())} +
+
+ + {has_outputs.then(|| view! { + + })} +
+ } +} diff --git a/apps/papillon/frontend/src/components/block_ports.rs b/apps/papillon/frontend/src/components/block_ports.rs new file mode 100644 index 00000000..6c052696 --- /dev/null +++ b/apps/papillon/frontend/src/components/block_ports.rs @@ -0,0 +1,45 @@ +use leptos::prelude::*; + +/// Input port displayed on the left side of a block container. +/// Shows schema.org types this block accepts. +#[component] +pub fn BlockInputPort( + /// Schema.org types this port accepts (e.g., ["schema:Place"]) + types: Vec, + /// Whether this port has an active connection + connected: bool, +) -> impl IntoView { + let type_labels = types.iter() + .map(|t| t.strip_prefix("schema:").unwrap_or(t)) + .collect::>() + .join(", "); + + view! { +
+
+
{type_labels}
+
+ } +} + +/// Output port displayed on the right side of a block container. +/// Shows schema.org types this block produces. +#[component] +pub fn BlockOutputPort( + /// Schema.org types this port produces (e.g., ["schema:WeatherForecast"]) + types: Vec, + /// Whether this port has an active connection + connected: bool, +) -> impl IntoView { + let type_labels = types.iter() + .map(|t| t.strip_prefix("schema:").unwrap_or(t)) + .collect::>() + .join(", "); + + view! { +
+
{type_labels}
+
+
+ } +} diff --git a/apps/papillon/frontend/src/components/canvas_tab_bar.rs b/apps/papillon/frontend/src/components/canvas_tab_bar.rs new file mode 100644 index 00000000..79caaaf0 --- /dev/null +++ b/apps/papillon/frontend/src/components/canvas_tab_bar.rs @@ -0,0 +1,305 @@ +use leptos::prelude::*; + +use crate::state::canvas::CanvasState; + +/// Browser-style tab bar for canvas navigation. +/// Shows the first 4 canvases sorted by `updated_at` (most recent first). +/// Remaining canvases appear in an overflow dropdown. +#[component] +pub fn CanvasTabBar() -> impl IntoView { + let canvas_state = expect_context::(); + let menu_open = RwSignal::new(false); + + let on_new_canvas = move |_: leptos::ev::MouseEvent| { + canvas_state.new_canvas(); + }; + + let toggle_menu = move |_: leptos::ev::MouseEvent| { + menu_open.update(|v| *v = !*v); + }; + + view! { +
+ // Logo/brand button - opens settings panel + + +
+ >() + } + key=|c| c.id.clone() + children=move |canvas| { + let id = canvas.id.clone(); + view! { + + } + } + /> + {move || { + let mut canvases = canvas_state.canvases.get(); + canvases.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + let overflow: Vec<_> = canvases.into_iter().skip(4).collect(); + if !overflow.is_empty() { + Some(view! { + + }) + } else { + None + } + }} +
+ + + // Slide-in settings panel + +
+ } +} + +/// Individual canvas tab. +#[component] +fn CanvasTab(canvas_id: String, name: String) -> impl IntoView { + let canvas_state = expect_context::(); + + let is_active = Memo::new({ + let id = canvas_id.clone(); + move |_| canvas_state.current_canvas_id.get().as_deref() == Some(&id) + }); + + let display_name = if name.len() > 20 { + format!("{}…", &name[..20]) + } else { + name + }; + + let id_for_click = canvas_id.clone(); + let on_click = move |_: leptos::ev::MouseEvent| { + canvas_state.current_canvas_id.set(Some(id_for_click.clone())); + }; + + let id_for_close = canvas_id.clone(); + let on_close = move |e: leptos::ev::MouseEvent| { + e.stop_propagation(); + canvas_state.delete_canvas(&id_for_close); + }; + + view! { +
+ {display_name} + +
+ } +} + +/// Dropdown for canvases beyond the first 4. +#[component] +fn OverflowDropdown(canvases: Vec) -> impl IntoView { + let canvas_state = expect_context::(); + let open = RwSignal::new(false); + + let toggle = move |_: leptos::ev::MouseEvent| { + open.update(|o| *o = !*o); + }; + + view! { +
+ + {move || { + if open.get() { + let canvas_list = canvases.clone(); + Some(view! { +
+ + {canvas.name} + {relative_time} + + } + } + /> +
+ }) + } else { + None + } + }} +
+ } +} + +/// Format a timestamp as a relative time string (e.g., "2m ago", "1h ago"). +fn format_relative_time(iso_timestamp: &str) -> String { + let now = js_sys::Date::now(); + let then = js_sys::Date::parse(iso_timestamp); + if then.is_nan() { + return String::new(); + } + let delta_ms = now - then; + let delta_sec = (delta_ms / 1000.0) as i64; + + if delta_sec < 60 { + "just now".into() + } else if delta_sec < 3600 { + format!("{}m ago", delta_sec / 60) + } else if delta_sec < 86400 { + format!("{}h ago", delta_sec / 3600) + } else { + format!("{}d ago", delta_sec / 86400) + } +} + +/// Clean settings panel with theme toggle and All Settings link. +#[component] +fn SettingsPanel(open: RwSignal) -> impl IntoView { + let close_menu = move |_: leptos::ev::MouseEvent| { + open.set(false); + }; + + view! { + // Backdrop — click to close +
+ + // Slide-in panel +
+
+ // ── Settings ── + + + { + let show_settings = expect_context::>(); + view! { + + } + } +
+
+ } +} + +/// Inline Dark / Light / Auto theme toggle row. +/// Reads/writes data-theme on and persists to localStorage. +#[component] +fn ThemeToggleRow() -> impl IntoView { + let theme = RwSignal::new( + web_sys::window() + .and_then(|w| w.local_storage().ok().flatten()) + .and_then(|s: web_sys::Storage| s.get_item("papillon_theme").ok().flatten()) + .unwrap_or_else(|| "dark".to_string()), + ); + + let set_theme = move |t: &'static str| { + theme.set(t.to_string()); + if let Some(win) = web_sys::window() { + if let Some(doc) = win.document() { + let _ = doc.document_element() + .map(|el| el.set_attribute("data-theme", t)); + } + if let Ok(Some(storage)) = win.local_storage() { + let _ = storage.set_item("papillon_theme", t); + } + } + }; + + view! { +
+ + + + + + + "Theme" +
+ + + +
+
+ } +} + +#[component] +fn IconGear() -> impl IntoView { + view! { + + + + + } +} diff --git a/apps/papillon/frontend/src/components/disclosure_form.rs b/apps/papillon/frontend/src/components/disclosure_form.rs new file mode 100644 index 00000000..426514e7 --- /dev/null +++ b/apps/papillon/frontend/src/components/disclosure_form.rs @@ -0,0 +1,203 @@ +use leptos::prelude::*; +use papillon_shared::IntentPlan; +use std::collections::HashMap; +use wasm_bindgen_futures::spawn_local; + +use crate::commands::approval::{approve_intent_plan, ApprovalPayload, SignedChallenge}; +use crate::state::canvas::CanvasState; + +#[component] +pub fn DisclosureForm( + plan: IntentPlan, + selected_agents: ReadSignal>, +) -> impl IntoView { + // Form field values stored in local signal + let (field_values, set_field_values) = signal(HashMap::::new()); + + // Compute union of requires_disclosure from selected agents + let disclosure_props = Memo::new(move |_| { + let selected = selected_agents.get(); + if selected.is_empty() { + return Vec::new(); + } + + let mut props = Vec::new(); + for agent in &plan.candidates { + if selected.contains(&agent.did) { + for prop in &agent.requires_disclosure { + if !props.contains(prop) { + props.push(prop.clone()); + } + } + } + } + props.sort(); + props + }); + + let agent_count = Memo::new(move |_| selected_agents.get().len()); + + view! { +
+
+ "DISCLOSE" + + {move || disclosure_props.get().len()} + " " + {move || if disclosure_props.get().len() == 1 { "property" } else { "properties" }} + +
+ +
+ + } + } + /> +
+ + +
+ } +} + +#[component] +fn DisclosureField( + property: String, + field_values: ReadSignal>, + set_field_values: WriteSignal>, +) -> impl IntoView { + let prop_clone = property.clone(); + let prop_for_input = property.clone(); + + // Infer field type from property name + let field_type = infer_field_type(&property); + + // Humanize property name + let label = humanize_property(&property); + + let current_value = Memo::new(move |_| { + field_values + .get() + .get(&prop_clone) + .cloned() + .unwrap_or_default() + }); + + view! { +
+ + +
+ } +} + +/// Infer HTML input type from property name +fn infer_field_type(property: &str) -> &'static str { + let lower = property.to_lowercase(); + + if lower.contains("email") { + "email" + } else if lower.contains("date") || lower.contains("time") { + "date" + } else if lower.contains("url") || lower.contains("website") { + "url" + } else if lower.contains("phone") || lower.contains("tel") { + "tel" + } else { + "text" + } +} + +/// Humanize property name: strip "schema:", capitalize, handle dot notation +fn humanize_property(property: &str) -> String { + // Strip "schema:" prefix + let without_schema = property.strip_prefix("schema:").unwrap_or(property); + + // Handle dot notation: take last segment + let last_segment = without_schema.split('.').last().unwrap_or(without_schema); + + // Split camelCase/PascalCase into words + let mut result = String::new(); + let mut prev_was_lower = false; + + for ch in last_segment.chars() { + if ch.is_uppercase() && prev_was_lower { + result.push(' '); + } + result.push(ch); + prev_was_lower = ch.is_lowercase(); + } + + // Capitalize first letter + if let Some(first) = result.chars().next() { + first.to_uppercase().collect::() + &result[first.len_utf8()..] + } else { + result + } +} diff --git a/apps/papillon/frontend/src/components/ghost_block.rs b/apps/papillon/frontend/src/components/ghost_block.rs new file mode 100644 index 00000000..55fdd6c8 --- /dev/null +++ b/apps/papillon/frontend/src/components/ghost_block.rs @@ -0,0 +1,91 @@ +use leptos::prelude::*; +use papillon_shared::IntentPlan; + +#[component] +pub fn GhostBlockRenderer(plan: IntentPlan) -> impl IntoView { + let primary_return_type = plan + .returns + .first() + .cloned() + .unwrap_or_else(|| "schema:Thing".to_string()); + + let schema_icon = get_schema_icon(&primary_return_type); + let agent_count = plan.candidates.len(); + + view! { +
+
+ {schema_icon} + {primary_return_type.clone()} + + "From " {agent_count} " " + {if agent_count == 1 { "agent" } else { "agents" }} + +
+
+ +
+
+ } +} + +#[component] +fn SkeletonPreview(schema_type: String) -> impl IntoView { + view! { +
+ {match schema_type.as_str() { + "schema:FlightReservation" => view! { +
+ + + + +
+ }.into_any(), + "schema:WeatherForecast" => view! { +
+ + + +
+ }.into_any(), + "schema:NewsArticle" => view! { +
+ + + +
+ }.into_any(), + _ => view! { +
+ + + +
+ }.into_any(), + }} +
+ } +} + +#[component] +fn SkeletonField(label: &'static str, value: &'static str) -> impl IntoView { + view! { +
+ {label}":" + {value} +
+ } +} + +fn get_schema_icon(schema_type: &str) -> &'static str { + match schema_type { + "schema:FlightReservation" => "✈️", + "schema:WeatherForecast" => "🌤️", + "schema:NewsArticle" => "📰", + "schema:Product" => "🛍️", + "schema:Event" => "📅", + "schema:Recipe" => "🍳", + _ => "📄", + } +} diff --git a/apps/papillon/frontend/src/components/inline_prompt.rs b/apps/papillon/frontend/src/components/inline_prompt.rs new file mode 100644 index 00000000..aedb8e27 --- /dev/null +++ b/apps/papillon/frontend/src/components/inline_prompt.rs @@ -0,0 +1,262 @@ +use leptos::prelude::*; +use leptos::{ev, html}; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::JsCast; + +use crate::components::canvas_aside::AsideOpen; +use crate::state::canvas::CanvasState; +use crate::state::catalog::CatalogState; + +/// Inline prompt input for canvas - similar to the old topbar prompt but lives within the canvas. +#[component] +pub fn InlinePrompt() -> impl IntoView { + let canvas_state = expect_context::(); + let catalog_state = use_context::(); + let aside_open = use_context::().map(|AsideOpen(open)| open); + let input_ref = NodeRef::::new(); + let input_value = RwSignal::new(String::new()); + let selected_idx: RwSignal> = RwSignal::new(None); + + // Live pap:// completions from the catalog, plus web-browse for domains. + // + // - Web domain (prefix contains '.') → single "Browse → domain" suggestion. + // Any external domain resolves to HttpsEndpoint and routes to Web Page Reader. + // - Catalog name (no dot) → agent name completions from local catalog. + let pap_suggestions = Memo::new(move |_| { + let val = input_value.get(); + if !val.starts_with("pap://") { + return vec![]; + } + let prefix = val["pap://".len()..].to_lowercase(); + if prefix.is_empty() { + return vec![]; + } + // Web domain: show two suggestions — + // 1. "Browse → pap://domain" (existing direct-browse path) + // 2. "Check for PAP agents at domain" (well-known discovery) + if prefix.contains('.') { + return vec![ + prefix.clone(), + format!("__pap_discover__:{prefix}"), + ]; + } + // Catalog agent name match. + let entries = catalog_state + .map(|c| c.entries.get()) + .unwrap_or_default(); + let mut names: Vec = entries + .keys() + .filter(|k| k.starts_with(&prefix)) + .take(8) + .cloned() + .collect(); + names.sort(); + names + }); + + let show_pap_suggestions = Memo::new(move |_| { + input_value.get().starts_with("pap://") && !pap_suggestions.get().is_empty() + }); + + let submit = move || { + let text = input_value.get(); + if text.trim().is_empty() { + return; + } + canvas_state.submit_prompt(text.clone()); + if let Some(open) = aside_open { + open.set(true); + } + input_value.set(String::new()); + selected_idx.set(None); + }; + + let on_keydown = move |e: ev::KeyboardEvent| { + let suggestions = pap_suggestions.get_untracked(); + match e.key().as_str() { + "Enter" => { + if let Some(idx) = selected_idx.get_untracked() { + if let Some(name) = suggestions.get(idx) { + if name.starts_with("__pap_discover__:") { + // PAP discovery suggestion: submit as pap+discovery:// + let domain = name + .strip_prefix("__pap_discover__:") + .unwrap_or(name) + .to_string(); + canvas_state.submit_prompt(format!("pap+discovery://{domain}")); + input_value.set(String::new()); + selected_idx.set(None); + return; + } + let full = format!("pap://{name}"); + if name.contains('.') { + // Web domain: submit immediately. + canvas_state.submit_prompt(full); + input_value.set(String::new()); + } else { + // Catalog agent: fill the address bar so the user + // can review / refine before submitting. + input_value.set(full); + } + selected_idx.set(None); + return; + } + } + submit(); + } + "ArrowDown" if !suggestions.is_empty() => { + e.prevent_default(); + let next = match selected_idx.get_untracked() { + None => 0, + Some(i) => (i + 1).min(suggestions.len() - 1), + }; + selected_idx.set(Some(next)); + } + "ArrowUp" if !suggestions.is_empty() => { + e.prevent_default(); + let prev = match selected_idx.get_untracked() { + None | Some(0) => None, + Some(i) => Some(i - 1), + }; + selected_idx.set(prev); + } + "Escape" => { + selected_idx.set(None); + } + _ => {} + } + }; + + // Pick up prefill text set by agent tile clicks in the canvas empty state. + Effect::new(move || { + if let Some(text) = canvas_state.prefill_prompt.get() { + input_value.set(text); + canvas_state.prefill_prompt.set(None); + } + }); + + // Focus on mount and whenever focus_prompt is bumped (⌘K). + Effect::new(move || { + let _ = canvas_state.focus_prompt.get(); + let el_opt = input_ref.get(); + let cb = Closure::once(move || { + if let Some(el) = el_opt { + let _ = el.focus(); + } + }); + let window = web_sys::window().unwrap(); + let _ = window + .set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), 50); + cb.forget(); + }); + + view! { +
+ + +
+ {move || pap_suggestions.get().into_iter().enumerate().map(|(i, name)| { + let name_for_click = name.clone(); + let is_discovery = name.starts_with("__pap_discover__:"); + let is_web_domain = !is_discovery && name.contains('.'); + + view! { + + } + }).collect::>()} +
+
+
+ } +} diff --git a/apps/papillon/frontend/src/components/mod.rs b/apps/papillon/frontend/src/components/mod.rs index da873910..2ac47a7e 100644 --- a/apps/papillon/frontend/src/components/mod.rs +++ b/apps/papillon/frontend/src/components/mod.rs @@ -1,17 +1,32 @@ pub mod address_bar; +pub mod agent_curation_list; pub mod agent_picker_modal; +pub mod agent_selector; +pub mod approval_toast; pub mod canvas_aside; +pub mod block_container_view; pub mod block_renderer; +pub mod block_ports; pub mod canvas_back_face; pub mod canvas_chat_thread; pub mod canvas_empty_state; pub mod canvas_ghost_run_panel; pub mod canvas_surface_title; +pub mod canvas_tab_bar; pub mod canvas_workflow_pipeline; +pub mod disclosure_form; +pub mod ghost_block; pub mod hitl_gate; +pub mod inline_prompt; pub mod outcome_summary; pub mod profile_avatar; pub mod recovery_setup; pub mod registry; pub mod setup_wizard; pub mod topbar; +pub mod workflow_chat_thread; +pub mod workflow_panel; + +pub use agent_selector::AgentSelector; +pub use block_container_view::BlockContainerView; +pub use block_ports::{BlockInputPort, BlockOutputPort}; diff --git a/apps/papillon/frontend/src/components/topbar.rs b/apps/papillon/frontend/src/components/topbar.rs index 6d8ee7a4..6e73bd77 100644 --- a/apps/papillon/frontend/src/components/topbar.rs +++ b/apps/papillon/frontend/src/components/topbar.rs @@ -6,6 +6,7 @@ use wasm_bindgen::closure::Closure; use wasm_bindgen::JsCast; use crate::components::canvas_aside::AsideOpen; +use crate::components::canvas_tab_bar::CanvasTabBar; use crate::state::canvas::{CanvasSide, CanvasState}; use crate::state::catalog::CatalogState; @@ -70,6 +71,8 @@ pub fn TopBar() -> impl IntoView {
+ + // Backdrop — click to close
impl IntoView { + let canvas_state = expect_context::(); + let messages = canvas_state.canvas_messages; + + let all_messages = move || messages.get(); + + view! { +
+ + "No messages yet" +
+ } + > + } + } + /> + +
+ } +} + +#[component] +fn ChatMessage(message: CanvasMessageRecord) -> impl IntoView { + let is_user = message.role == "user"; + let formatted_time = format_message_time(&message.created_at); + + view! { +
+
+ + {if is_user { "You" } else { "Papillon" }} + + {formatted_time} +
+
+ {message.content} +
+
+ } +} + +fn format_message_time(timestamp: &str) -> String { + // Simplified: just show timestamp + // TODO: Implement relative time formatting + timestamp.to_string() +} diff --git a/apps/papillon/frontend/src/components/workflow_panel.rs b/apps/papillon/frontend/src/components/workflow_panel.rs new file mode 100644 index 00000000..0d9cdb8d --- /dev/null +++ b/apps/papillon/frontend/src/components/workflow_panel.rs @@ -0,0 +1,83 @@ +use leptos::prelude::*; +use crate::state::canvas::CanvasState; +use crate::components::workflow_chat_thread::WorkflowChatThread; +use crate::components::agent_curation_list::AgentCurationList; +use crate::components::disclosure_form::DisclosureForm; + +/// Slide-in workflow panel from the right side. +/// Shows chat, curation, and disclosure sections for the active workflow. +#[component] +pub fn WorkflowPanel() -> impl IntoView { + let canvas_state = expect_context::(); + let is_open = canvas_state.workflow_panel_open; + + // Active plan from CanvasState (populated by canvas_plan_prompt command) + let active_plan = canvas_state.active_intent_plan; + + // Close handler + let close = move |_| { + canvas_state.workflow_panel_open.set(false); + }; + + view! { +
+ +
+
+

"Workflow"

+ +
+ +
+ + "No active plan" +
+ } + > + {move || { + active_plan.get().map(|plan| { + // Create selected agents signal for this plan + let selected_agents = RwSignal::new(Vec::::new()); + + view! { + {/* Chat section */} +
+

"Chat"

+ +
+ + {/* Curation section */} +
+

"Curation"

+ +
+ + {/* Disclosure section */} +
+

"Disclosure"

+ +
+ } + }) + }} + +
+
+ } +} diff --git a/apps/papillon/frontend/src/lib.rs b/apps/papillon/frontend/src/lib.rs index b86cde66..d8ef5101 100644 --- a/apps/papillon/frontend/src/lib.rs +++ b/apps/papillon/frontend/src/lib.rs @@ -1,5 +1,6 @@ mod app; mod bridge; +pub mod commands; pub mod components; pub mod handshake; pub mod orchestrator_runtime; diff --git a/apps/papillon/frontend/src/pages/canvas.rs b/apps/papillon/frontend/src/pages/canvas.rs index d8d36045..0dfe1baa 100644 --- a/apps/papillon/frontend/src/pages/canvas.rs +++ b/apps/papillon/frontend/src/pages/canvas.rs @@ -1,11 +1,16 @@ use leptos::prelude::*; +use leptos::ev; use papillon_shared::{BlockState, CanvasBlock}; +use crate::components::approval_toast::ApprovalToastStack; use crate::components::block_renderer::BlockRenderer; use crate::components::canvas_aside::{AsideOpen, CanvasAside, CanvasAsideDockToggle}; use crate::components::canvas_back_face::CanvasBackFace; use crate::components::canvas_surface_title::CanvasSurfaceTitle; +use crate::components::canvas_tab_bar::CanvasTabBar; use crate::components::hitl_gate::HitlGate; +use crate::components::inline_prompt::InlinePrompt; +use crate::components::workflow_panel::WorkflowPanel; use crate::state::canvas::{CanvasSide, CanvasState}; #[component] @@ -57,10 +62,66 @@ pub fn CanvasPage() -> impl IntoView { let is_back = move || canvas_state.canvas_side.get() == CanvasSide::Back; let aside_open = use_context::().map(|AsideOpen(open)| open).unwrap_or_else(|| RwSignal::new(false)); + let toggle_side = move |_: leptos::ev::MouseEvent| { + canvas_state.canvas_side.update(|s| { + *s = if *s == CanvasSide::Front { + CanvasSide::Back + } else { + CanvasSide::Front + }; + }); + }; + + // Keyboard shortcut handler + let handle_keydown = move |e: ev::KeyboardEvent| { + if !e.ctrl_key() { + return; + } + + match e.key().as_str() { + "t" | "T" => { + e.prevent_default(); + canvas_state.new_canvas(); + } + "w" | "W" => { + e.prevent_default(); + if let Some(current_id) = canvas_state.current_canvas_id.get() { + canvas_state.delete_canvas(¤t_id); + } + } + "\\" => { + e.prevent_default(); + canvas_state.workflow_panel_open.update(|open| *open = !*open); + } + "Tab" => { + e.prevent_default(); + cycle_canvas_forward(&canvas_state); + } + _ => {} + } + }; + view! { -
+ // Tab bar at the top with logo/settings + + +
+ // Canvas header with inline prompt and workflow toggle +
+
+ +
+ +
+ // Flip container.
impl IntoView { children=move |group| { match group { BlockGroup::Single(block) => { - view! { }.into_any() + // Check if this block has a container_id + if block.container_id.is_some() { + // TODO: Fetch BlockContainer from backend and render BlockContainerView + // For now, fall back to legacy renderer + view! { }.into_any() + } else { + view! { }.into_any() + } } BlockGroup::Linked(blocks) => { view! { @@ -113,6 +181,9 @@ pub fn CanvasPage() -> impl IntoView {
+ + +
} } @@ -122,3 +193,26 @@ enum BlockGroup { Single(CanvasBlock), Linked(Vec), } + +/// Cycle to the next canvas in the sorted list (wrapping around). +fn cycle_canvas_forward(canvas_state: &CanvasState) { + let current_id = match canvas_state.current_canvas_id.get() { + Some(id) => id, + None => return, + }; + + let mut canvases = canvas_state.canvases.get(); + // Sort by updated_at descending (most recent first) to match sidebar order + canvases.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + + let current_index = canvases.iter().position(|c| c.id == current_id); + + let next_index = match current_index { + Some(idx) => (idx + 1) % canvases.len(), + None => 0, + }; + + if let Some(next_canvas) = canvases.get(next_index) { + canvas_state.current_canvas_id.set(Some(next_canvas.id.clone())); + } +} diff --git a/apps/papillon/frontend/src/state/canvas.rs b/apps/papillon/frontend/src/state/canvas.rs index a62cce2f..b91f7f43 100644 --- a/apps/papillon/frontend/src/state/canvas.rs +++ b/apps/papillon/frontend/src/state/canvas.rs @@ -88,6 +88,10 @@ pub struct CanvasState { pub last_event: RwSignal>, /// Live workflow graph for the active canvas, derived from block state. pub workflow_graph: RwSignal, + /// Whether the workflow panel is open (toggled by toast clicks and Ctrl+\). + pub workflow_panel_open: RwSignal, + /// Active IntentPlan for the workflow panel, populated by canvas_plan_prompt. + pub active_intent_plan: RwSignal>, } impl Default for CanvasState { @@ -107,6 +111,8 @@ impl Default for CanvasState { block_template_overrides: RwSignal::new(std::collections::HashMap::new()), last_event: RwSignal::new(None), workflow_graph: RwSignal::new(papillon_shared::WorkflowGraph::default()), + workflow_panel_open: RwSignal::new(false), + active_intent_plan: RwSignal::new(None), } } } @@ -717,6 +723,7 @@ impl CanvasState { content: None, linked_block_ids, agent_did: None, + container_id: None, mandate_expires_at: None, preference_guided: false, auto_expand, @@ -1422,6 +1429,7 @@ impl CanvasState { content: None, linked_block_ids: Vec::new(), agent_did: None, + container_id: None, mandate_expires_at: None, preference_guided: false, auto_expand: false, @@ -1551,6 +1559,7 @@ impl CanvasState { content, linked_block_ids: Vec::new(), agent_did: rec.agent_did, + container_id: None, mandate_expires_at: rec.mandate_expires_at, preference_guided: rec.preference_guided, auto_expand: false, diff --git a/apps/papillon/frontend/styles/main.css b/apps/papillon/frontend/styles/main.css index efa4c244..8ebf36ce 100644 --- a/apps/papillon/frontend/styles/main.css +++ b/apps/papillon/frontend/styles/main.css @@ -5236,6 +5236,39 @@ body { var(--bg-0); } +.canvas-header { + display: flex; + align-items: center; + gap: var(--sp-md); + padding: var(--sp-md) var(--sp-lg); + background: var(--bg-primary); + border-bottom: 1px solid var(--border-subtle); + z-index: 10; +} + +.canvas-header-prompt { + flex: 1; + max-width: 800px; +} + +.canvas-flip-toggle { + padding: var(--sp-sm) var(--sp-md); + background: none; + border: 1px solid var(--border); + border-radius: var(--r-md); + font: var(--text-ui); + color: var(--text-secondary); + cursor: pointer; + transition: all 150ms ease; + white-space: nowrap; +} + +.canvas-flip-toggle:hover { + background: var(--purple-muted); + border-color: var(--purple); + color: var(--purple); +} + .canvas-prompt-bar { flex-shrink: 0; padding: var(--sp-md); @@ -5244,6 +5277,49 @@ body { z-index: 10; } +/* Inline prompt component (replaces topbar prompt within canvas) */ +.inline-prompt { + position: relative; + width: 100%; + display: flex; + align-items: center; +} + +.inline-prompt-input { + flex: 1; + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: 14px; + padding: 11px 16px; + font-family: var(--font-body); + font-size: 14px; + color: var(--text-1); + outline: none; + transition: border-color 0.15s, background 0.15s; +} + +.inline-prompt-input::placeholder { + color: var(--text-3); +} + +.inline-prompt-input:focus { + border-color: var(--purple); + background: rgba(108, 92, 231, 0.08); +} + +.inline-prompt-suggestions { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--r-lg); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + z-index: 50; + overflow: hidden; +} + .canvas-stream { flex: 1; overflow-y: auto; @@ -9300,3 +9376,1187 @@ body { .schema-suggestion:hover { background: var(--bg-tertiary); } + +/* ═══════════════════════════════════════════════════════════ + CANVAS TAB BAR + ═══════════════════════════════════════════════════════════ */ + +.canvas-tab-bar { + display: flex; + align-items: center; + gap: var(--sp-xs); + height: 48px; + padding: 0 var(--sp-md); + background: var(--bg-primary); + border-bottom: 1px solid var(--border-subtle); + position: relative; +} + +.canvas-tab-brand { + display: flex; + align-items: center; + justify-content: center; + height: 36px; + width: 36px; + padding: 0; + background: none; + border: none; + border-radius: var(--r-md); + cursor: pointer; + transition: background 150ms ease; + margin-right: var(--sp-sm); +} + +.canvas-tab-brand:hover { + background: var(--purple-muted); +} + +.canvas-tab-logo { + width: 24px; + height: 24px; + object-fit: contain; +} + +.canvas-tabs { + display: flex; + align-items: center; + gap: var(--sp-xs); + flex: 1; +} + +.canvas-tab { + display: flex; + align-items: center; + gap: var(--sp-sm); + min-width: 120px; + max-width: 180px; + height: 36px; + padding: 0 var(--sp-md); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + border-radius: var(--r-md) var(--r-md) 0 0; + cursor: pointer; + transition: all 150ms ease; + font: var(--text-ui); + color: var(--text-secondary); +} + +.canvas-tab:hover { + background: var(--purple-muted); + color: var(--text-primary); +} + +.canvas-tab.active { + border-bottom-color: var(--purple); + font-weight: 700; + color: var(--text-primary); +} + +.canvas-tab-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.canvas-tab-close { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + background: none; + border: none; + border-radius: var(--r-sm); + cursor: pointer; + font-size: 16px; + line-height: 1; + color: var(--text-secondary); + opacity: 0; + transition: opacity 150ms ease; +} + +.canvas-tab:hover .canvas-tab-close { + opacity: 1; +} + +.canvas-tab-close:hover { + background: var(--purple-muted); + color: var(--text-primary); +} + +.new-tab-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: none; + border: 1px solid var(--border); + border-radius: var(--r-md); + cursor: pointer; + font-size: 18px; + line-height: 1; + color: var(--text-secondary); + transition: all 150ms ease; +} + +.new-tab-btn:hover { + background: var(--purple-muted); + border-color: var(--purple); + color: var(--purple); +} + +.overflow-dropdown-container { + position: relative; + margin-left: auto; +} + +.overflow-dropdown-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: none; + border: 1px solid var(--border); + border-radius: var(--r-md); + cursor: pointer; + font-size: 18px; + line-height: 1; + color: var(--text-secondary); + transition: all 150ms ease; +} + +.overflow-dropdown-toggle:hover { + background: var(--purple-muted); + border-color: var(--purple); + color: var(--purple); +} + +.overflow-dropdown-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: var(--sp-xs); + min-width: 240px; + max-height: 400px; + overflow-y: auto; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--r-md); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + z-index: 1000; +} + +.overflow-dropdown-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: var(--sp-md); + background: none; + border: none; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; + font: var(--text-ui); + color: var(--text-primary); + text-align: left; + transition: background 150ms ease; +} + +.overflow-dropdown-item:last-child { + border-bottom: none; +} + +.overflow-dropdown-item:hover { + background: var(--purple-muted); +} + +.overflow-canvas-name { + flex: 1; + font-weight: 500; +} + +.overflow-canvas-time { + font: var(--text-small); + color: var(--text-secondary); +} + +/* ======================================================================== + WORKFLOW PANEL + ======================================================================== */ + +/* Backdrop overlay */ +.workflow-panel-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0); + z-index: 999; + pointer-events: none; + transition: background 250ms ease; +} + +.workflow-panel-backdrop.open { + background: rgba(0, 0, 0, 0.5); + pointer-events: auto; +} + +/* Panel container */ +.workflow-panel { + position: fixed; + top: 0; + right: 0; + width: 400px; + height: 100vh; + background: var(--bg-primary); + border-left: 1px solid var(--border); + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3); + z-index: 1000; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 250ms ease; +} + +.workflow-panel.open { + transform: translateX(0); +} + +/* Panel header */ +.workflow-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-lg); + border-bottom: 1px solid var(--border); + background: var(--bg-secondary); +} + +.workflow-panel-header h2 { + font: var(--heading-md); + color: var(--text-primary); + margin: 0; +} + +.workflow-panel-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: none; + border: none; + border-radius: var(--r-sm); + cursor: pointer; + color: var(--text-secondary); + font-size: 20px; + transition: all 150ms ease; +} + +.workflow-panel-close:hover { + background: var(--purple-muted); + color: var(--purple); +} + +/* Panel body */ +.workflow-panel-body { + flex: 1; + overflow-y: auto; + padding: var(--sp-lg); +} + +/* Workflow sections */ +.workflow-section { + margin-bottom: var(--sp-xl); +} + +.workflow-section:last-child { + margin-bottom: 0; +} + +.workflow-section-title { + font: var(--text-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: var(--sp-md); +} + +.workflow-section-content { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: var(--sp-md); +} + +/* Agent info display */ +.workflow-agent-info { + display: flex; + align-items: flex-start; + gap: var(--sp-md); +} + +.workflow-agent-avatar { + width: 40px; + height: 40px; + border-radius: var(--r-md); + background: var(--purple-muted); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.workflow-agent-details { + flex: 1; + min-width: 0; +} + +.workflow-agent-name { + font: var(--text-ui); + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--sp-xs); +} + +.workflow-agent-did { + font: var(--mono-small); + color: var(--text-secondary); + word-break: break-all; +} + +/* Disclosure list */ +.workflow-disclosure-list { + list-style: none; + padding: 0; + margin: 0; +} + +.workflow-disclosure-item { + display: flex; + align-items: center; + gap: var(--sp-sm); + padding: var(--sp-sm) 0; + border-bottom: 1px solid var(--border-subtle); +} + +.workflow-disclosure-item:last-child { + border-bottom: none; +} + +.workflow-disclosure-icon { + color: var(--purple); + font-size: 16px; + flex-shrink: 0; +} + +.workflow-disclosure-field { + flex: 1; + font: var(--text-ui); + color: var(--text-primary); +} + +/* Returns schema display */ +.workflow-returns-schema { + font: var(--mono-small); + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: var(--sp-sm); + border-radius: var(--r-sm); + border: 1px solid var(--border-subtle); +} + +/* Mandate info */ +.workflow-mandate-info { + display: flex; + flex-direction: column; + gap: var(--sp-sm); +} + +.workflow-mandate-row { + display: flex; + justify-content: space-between; + align-items: center; + font: var(--text-ui); +} + +.workflow-mandate-label { + color: var(--text-secondary); + font-weight: 500; +} + +.workflow-mandate-value { + color: var(--text-primary); + font-weight: 600; +} + +/* Panel footer */ +.workflow-panel-footer { + padding: var(--sp-lg); + border-top: 1px solid var(--border); + background: var(--bg-secondary); + display: flex; + gap: var(--sp-md); +} + +.workflow-approve-btn { + flex: 1; + padding: var(--sp-md) var(--sp-lg); + background: var(--purple); + color: white; + border: none; + border-radius: var(--r-md); + font: var(--text-ui); + font-weight: 600; + cursor: pointer; + transition: all 150ms ease; +} + +.workflow-approve-btn:hover { + background: var(--purple-dark); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(108, 92, 231, 0.3); +} + +.workflow-approve-btn:active { + transform: translateY(0); +} + +.workflow-reject-btn { + padding: var(--sp-md) var(--sp-lg); + background: none; + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: var(--r-md); + font: var(--text-ui); + font-weight: 600; + cursor: pointer; + transition: all 150ms ease; +} + +.workflow-reject-btn:hover { + background: var(--bg-tertiary); + border-color: var(--text-secondary); + color: var(--text-primary); +} + +/* No plan state */ +.workflow-no-plan { + display: flex; + align-items: center; + justify-content: center; + padding: var(--sp-2xl); + font: var(--text-ui); + color: var(--text-secondary); + text-align: center; +} + +/* ═══════════════════════════════════════════════════════════ + WORKFLOW CHAT THREAD + ═══════════════════════════════════════════════════════════ */ + +.workflow-chat { + max-height: 200px; + overflow-y: auto; + padding: var(--sp-md); + background: var(--bg-tertiary); + border-radius: var(--r-md); +} + +.workflow-chat-empty { + padding: var(--sp-lg); + font: var(--text-ui); + color: var(--text-secondary); + text-align: center; +} + +.chat-message { + padding: var(--sp-md); + margin-bottom: var(--sp-md); + background: var(--bg-secondary); + border-radius: var(--r-md); + border-left: 3px solid var(--border); +} + +.chat-message:last-child { + margin-bottom: 0; +} + +.chat-message.user { + border-left-color: var(--purple); +} + +.chat-message.assistant { + border-left-color: var(--teal); +} + +.chat-message-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--sp-xs); +} + +.chat-message-role { + font: var(--text-label); + font-weight: 700; + text-transform: uppercase; + color: var(--text-secondary); +} + +.chat-message-time { + font: var(--text-small); + color: var(--text-tertiary); +} + +.chat-message-content { + font: var(--text-ui); + color: var(--text-primary); + line-height: 1.5; +} +/* ═══════════════════════════════════════════════════════════ + AGENT CURATION LIST + ═══════════════════════════════════════════════════════════ */ + +.agent-curation-list { + display: flex; + flex-direction: column; + gap: var(--sp-sm); +} + +.agent-card { + padding: var(--sp-md); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--r-md); + transition: all 150ms ease; +} + +.agent-card:hover { + background: var(--surface-card-hover); + border-color: var(--surface-card-border-hover); +} + +.agent-card.selected { + background: var(--purple-muted); + border-color: var(--purple); +} + +.agent-card-checkbox-label { + display: flex; + align-items: flex-start; + gap: var(--sp-md); + cursor: pointer; +} + +.agent-card-checkbox { + margin-top: 2px; + flex-shrink: 0; +} + +.agent-card-info { + flex: 1; +} + +.agent-card-header { + display: flex; + align-items: center; + gap: var(--sp-sm); + margin-bottom: var(--sp-xs); +} + +.agent-card-name { + font: var(--text-ui); + font-weight: 700; + color: var(--text-primary); +} + +.trust-badge { + padding: 2px 8px; + border-radius: var(--r-sm); + font: var(--text-label); + font-size: 10px; +} + +.trust-badge.on-device { + background: var(--teal); + color: white; +} + +.agent-card-did { + display: block; + margin-bottom: var(--sp-sm); + font: var(--text-mono); + font-size: 11px; + color: var(--text-secondary); +} + +.agent-card-disclosure { + font: var(--text-small); + color: var(--text-secondary); +} + +.disclosure-count { + font-weight: 500; +} + +/* ═══════════════════════════════════════════════════════════ + Disclosure Form + ═══════════════════════════════════════════════════════════ */ + +.disclosure-form { + margin-top: var(--sp-lg); +} + +.disclosure-form-header { + display: flex; + align-items: center; + gap: var(--sp-sm); + margin-bottom: var(--sp-md); +} + +.disclosure-label { + font: var(--text-label); + font-weight: 700; + letter-spacing: var(--ls-label); + color: var(--text-secondary); + text-transform: uppercase; +} + +.disclosure-form-fields { + display: flex; + flex-direction: column; + gap: var(--sp-md); + margin-bottom: var(--sp-lg); +} + +.disclosure-field { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.disclosure-field-label { + font: var(--text-ui); + font-weight: 500; + color: var(--text-primary); +} + +.disclosure-field-input { + padding: var(--sp-sm) var(--sp-md); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--r-md); + font: var(--text-body); + color: var(--text-primary); + transition: all 150ms ease; +} + +.disclosure-field-input:focus { + outline: none; + border-color: var(--purple); + background: var(--bg-secondary); +} + +.disclosure-approve-button { + width: 100%; + padding: var(--sp-md); + background: var(--purple); + color: white; + border: none; + border-radius: var(--r-md); + font: var(--text-ui); + font-weight: 700; + cursor: pointer; + transition: all 150ms ease; +} + +.disclosure-approve-button:hover:not(:disabled) { + background: var(--purple-hover); +} + +.disclosure-approve-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ======================================================================== + APPROVAL TOAST + ======================================================================== */ + +@keyframes slideInUp { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.approval-toast { + position: fixed; + bottom: var(--sp-lg); + right: var(--sp-lg); + width: 360px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--r-md); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + z-index: 2000; + animation: slideInUp 250ms ease-out; + overflow: hidden; +} + +.approval-toast-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-md) var(--sp-lg); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.approval-toast-agent { + font: var(--text-ui); + font-weight: 600; + color: var(--text-primary); +} + +.approval-toast-dismiss { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: none; + border: none; + border-radius: var(--r-sm); + cursor: pointer; + color: var(--text-secondary); + font-size: 20px; + transition: all 150ms ease; +} + +.approval-toast-dismiss:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.approval-toast-body { + padding: var(--sp-lg); +} + +.approval-toast-action { + display: flex; + align-items: center; + gap: var(--sp-sm); + font: var(--text-ui); + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--sp-sm); +} + +.approval-toast-risk { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + font: var(--text-label); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.approval-toast-risk.high { + background: rgba(240, 160, 48, 0.15); + color: var(--gold); +} + +.approval-toast-risk.critical { + background: rgba(232, 72, 72, 0.15); + color: var(--coral); +} + +.approval-toast-description { + font: var(--text-ui); + color: var(--text-secondary); + margin-bottom: var(--sp-md); + line-height: 1.5; +} + +.approval-toast-disclosure { + font: var(--text-small); + color: var(--text-secondary); + padding: var(--sp-sm) var(--sp-md); + background: var(--bg-tertiary); + border-radius: var(--r-sm); + border-left: 2px solid var(--purple-muted); +} + +.approval-toast-footer { + padding: var(--sp-md) var(--sp-lg); + border-top: 1px solid var(--border); + background: var(--bg-secondary); +} + +.approval-toast-button { + width: 100%; + padding: var(--sp-md) var(--sp-lg); + border: none; + border-radius: var(--r-md); + font: var(--text-ui); + font-weight: 600; + cursor: pointer; + transition: all 150ms ease; +} + +.approval-toast-button.approve { + background: var(--purple); + color: white; +} + +.approval-toast-button.approve:hover { + background: var(--purple-dark); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(108, 92, 231, 0.3); +} + +.approval-toast-button.details { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.approval-toast-button.details:hover { + background: var(--purple-muted); + border-color: var(--purple); + color: var(--purple); +} + +/* ═══════════════════════════════════════════════════════════ + GHOST BLOCK + ═══════════════════════════════════════════════════════════ */ + +.ghost-block { + padding: var(--sp-lg); + background: var(--bg-secondary); + border: 2px dashed var(--purple); + border-radius: var(--r-lg); + opacity: 0.7; +} + +.ghost-header { + display: flex; + align-items: center; + gap: var(--sp-md); + margin-bottom: var(--sp-lg); + padding-bottom: var(--sp-md); + border-bottom: 1px solid var(--border-subtle); +} + +.ghost-schema-icon { + font-size: 24px; +} + +.ghost-schema-type { + flex: 1; + font: var(--text-ui); + font-weight: 700; + color: var(--text-primary); +} + +.ghost-agent-count { + font: var(--text-small); + color: var(--text-secondary); +} + +.ghost-skeleton { + font-family: var(--font-mono); +} + +.skeleton-container { + display: flex; + flex-direction: column; + gap: var(--sp-md); +} + +.skeleton-field { + display: flex; + gap: var(--sp-sm); +} + +.skeleton-label { + font-weight: 700; + color: var(--text-secondary); +} + +.skeleton-value { + color: var(--text-tertiary); + opacity: 0.5; +} + +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Block Ports (Node Graph Wiring) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + +.block-port { + display: flex; + align-items: center; + gap: var(--sp-xs); + padding: var(--sp-xs); +} + +.block-port-input { + justify-content: flex-start; + position: absolute; + left: -12px; + top: 50%; + transform: translateY(-50%); +} + +.block-port-output { + justify-content: flex-end; + position: absolute; + right: -12px; + top: 50%; + transform: translateY(-50%); +} + +.port-circle { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--text-3); + border: 2px solid var(--bg-secondary); + transition: all 0.2s ease; +} + +.block-port.connected .port-circle { + background: var(--teal); + box-shadow: 0 0 8px var(--teal); +} + +.block-port:hover .port-circle { + background: var(--purple); + transform: scale(1.2); + cursor: pointer; +} + +.port-label { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + background: var(--bg-primary); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--border); +} + +.block-port-input .port-label { + margin-left: 4px; +} + +.block-port-output .port-label { + margin-right: 4px; +} + +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Agent Selector (Multi-Agent Block) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + +.agent-selector { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + padding: var(--sp-sm); + max-width: 300px; +} + +.agent-selector-header { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--sp-xs); + padding-bottom: var(--sp-xs); + border-bottom: 1px solid var(--border-primary); +} + +.agent-selector-list { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + max-height: 300px; + overflow-y: auto; +} + +.agent-selector-item { + display: flex; + align-items: center; + gap: var(--sp-xs); + padding: var(--sp-xs); + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.2s ease; + text-align: left; +} + +.agent-selector-item:hover { + background: var(--bg-tertiary); + border-color: var(--brand-purple); +} + +.agent-selector-item.selected { + background: rgba(108, 92, 231, 0.1); + border-color: var(--brand-purple); +} + +.agent-checkbox { + width: 20px; + height: 20px; + border: 2px solid var(--border-primary); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + color: var(--brand-purple); + flex-shrink: 0; +} + +.agent-selector-item.selected .agent-checkbox { + background: var(--brand-purple); + color: white; + border-color: var(--brand-purple); +} + +.agent-info { + flex: 1; + min-width: 0; +} + +.agent-name { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.agent-provider { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Block Container (Node Graph) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + +.block-container { + position: absolute; + background: var(--bg-secondary); + border: 2px solid var(--border-primary); + border-radius: var(--radius-md); + min-width: 200px; + max-width: 300px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; +} + +.block-container:hover { + border-color: var(--brand-purple); + box-shadow: 0 4px 16px rgba(108, 92, 231, 0.2); +} + +.block-container-body { + padding: var(--sp-sm); +} + +.block-container-header { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--sp-xs); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.block-container-types { + display: flex; + align-items: center; + gap: var(--sp-xs); + margin-bottom: var(--sp-xs); + flex-wrap: wrap; +} + +.type-badge { + font-size: 0.75rem; + padding: 2px 6px; + border-radius: 4px; + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-primary); + white-space: nowrap; +} + +.type-badge.type-input { + border-color: var(--wing-teal); + color: var(--wing-teal); +} + +.type-badge.type-output { + border-color: var(--wing-gold); + color: var(--wing-gold); +} + +.type-arrow { + font-size: 1rem; + color: var(--text-secondary); + margin: 0 4px; +} + +.block-agent-count { + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: var(--sp-xs); + padding-top: var(--sp-xs); + border-top: 1px solid var(--border-primary); +} + +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Canvas Graph Mode + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + +.canvas-stream.graph-mode { + position: relative; + width: 100%; + height: 100%; + background: + linear-gradient(var(--border-primary) 1px, transparent 1px), + linear-gradient(90deg, var(--border-primary) 1px, transparent 1px); + background-size: 20px 20px; + overflow: auto; +} + +.canvas-stream.graph-mode .block-container { + position: absolute; +} diff --git a/apps/papillon/src/commands/canvas/container.rs b/apps/papillon/src/commands/canvas/container.rs new file mode 100644 index 00000000..3cff8e0f --- /dev/null +++ b/apps/papillon/src/commands/canvas/container.rs @@ -0,0 +1,116 @@ +use crate::AppState; +use papillon_shared::{AgentInfo, BlockContainer, BlockPosition, SchemaSignature}; +use rand::Rng; +use tauri::State; + +/// Create a new block container from an intent. +/// Uses BM25 to classify intent → schema action → agent signature. +#[tauri::command] +pub async fn create_block_container( + canvas_id: String, + _prompt: String, + position: Option, + state: State<'_, AppState>, +) -> Result { + // 1. Get agents from local registry + let local_registry = state.local_registry.lock().map_err(|e| e.to_string())?; + + let ads = local_registry.all_advertisements(); + let agents: Vec = ads + .iter() + .map(|ad| AgentInfo { + name: ad.name.clone(), + provider_name: ad.provider.name.clone(), + provider_did: ad.provider.did.clone(), + capabilities: ad.capability.clone(), + object_types: ad.object_types.clone(), + requires_disclosure: ad.requires_disclosure.clone(), + returns: ad.returns.clone(), + endpoint: None, // TODO: Extract from marketplace advertisement if available + content_hash: String::new(), + agent_did: Some(ad.signed_by.clone()), + source: "local".to_string(), + published_to: vec![], + live: true, + category: "general".to_string(), + execution_target: Default::default(), + lifecycle: Default::default(), + }) + .collect(); + + drop(local_registry); // Release lock + + // 2. For now, use a simple placeholder approach + // TODO: Use BM25 IntentIndex to classify prompt + // let index = IntentIndex::new(&dynamic_agents); + // let intent_match = index.classify(&prompt).ok_or("No intent match found")?; + + // Placeholder: Create a generic SearchAction signature + let signature = SchemaSignature { + input_types: vec![], + output_types: vec!["schema:SearchResult".to_string()], + }; + + // 3. Filter agents by matching signature + let compatible_agents: Vec = agents + .into_iter() + .filter(|agent| { + let agent_sig = SchemaSignature::from_agent(agent); + agent_sig.matches(&signature) + }) + .map(|a| a.name) + .collect(); + + if compatible_agents.is_empty() { + return Err(format!("No agents found for signature: {:?}", signature)); + } + + // 4. Create container with caller-supplied or auto-offset position + let position = position.unwrap_or_else(|| { + let mut rng = rand::rngs::OsRng; + BlockPosition { + x: 100.0 + rng.gen_range(0.0..=40.0), + y: 100.0 + rng.gen_range(0.0..=40.0), + } + }); + let container = BlockContainer { + id: uuid::Uuid::new_v4().to_string(), + canvas_id, + signature, + agent_names: compatible_agents, + position, + input_connections: vec![], + output_connections: vec![], + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }; + + // 5. Store container in DB (TODO: add table) + // For now, return the in-memory container + Ok(container) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_container_creation_logic() { + // This test validates the container creation logic without DB + let _canvas_id = "canvas-123"; + let _prompt = "weather in seattle"; + let _action_type = "schema:SearchAction"; + + // Signature would be derived from BM25 intent detection + let signature = SchemaSignature { + input_types: vec!["schema:Place".to_string()], + output_types: vec!["schema:WeatherForecast".to_string()], + }; + + // In real impl, agents would be filtered by signature.matches() + let agent_names: Vec = vec!["Weather Agent 1".to_string()]; + + assert!(!agent_names.is_empty()); + assert_eq!(signature.output_types.len(), 1); + } +} diff --git a/apps/papillon/src/commands/canvas/mod.rs b/apps/papillon/src/commands/canvas/mod.rs index 40edb2ba..4fc5d67c 100644 --- a/apps/papillon/src/commands/canvas/mod.rs +++ b/apps/papillon/src/commands/canvas/mod.rs @@ -2,6 +2,7 @@ mod approval; mod blocks; +mod container; mod crud; mod execution; mod guide; @@ -13,18 +14,21 @@ mod orchestration; mod outcome; mod resolution; mod types; +mod wiring; // ── Public Tauri commands (referenced by lib.rs generate_handler![]) ────── // Use glob re-exports so `__cmd__` symbols generated by #[tauri::command] // are also visible at the `commands::canvas::*` path used by generate_handler![]. pub use approval::*; pub use blocks::*; +pub use container::*; pub use crud::*; pub use guide::*; pub use messages::*; pub use notes::*; pub use orchestration::*; pub use outcome::*; +pub use wiring::*; // ── Internal re-exports used by sibling command modules ────────────────── pub(crate) use resolution::resolve_agent; diff --git a/apps/papillon/src/commands/canvas/notes.rs b/apps/papillon/src/commands/canvas/notes.rs index 7e9d9896..c13b535f 100644 --- a/apps/papillon/src/commands/canvas/notes.rs +++ b/apps/papillon/src/commands/canvas/notes.rs @@ -53,6 +53,7 @@ pub async fn canvas_create_note( content: Some(content_json), linked_block_ids: vec![], agent_did: None, + container_id: None, mandate_expires_at: None, preference_guided: false, auto_expand: false, @@ -135,6 +136,7 @@ pub async fn canvas_update_note( content: Some(content_json), linked_block_ids: vec![], agent_did: None, + container_id: None, mandate_expires_at: None, preference_guided: false, auto_expand: false, diff --git a/apps/papillon/src/commands/canvas/wiring.rs b/apps/papillon/src/commands/canvas/wiring.rs new file mode 100644 index 00000000..6bebed20 --- /dev/null +++ b/apps/papillon/src/commands/canvas/wiring.rs @@ -0,0 +1,80 @@ +use crate::AppState; +use papillon_shared::BlockConnection; +use tauri::State; + +/// Connect two block containers. +/// Validates that from_block outputs match to_block inputs. +#[tauri::command] +pub async fn connect_blocks( + from_block_id: String, + to_block_id: String, + _state: State<'_, AppState>, +) -> Result { + // 1. Fetch both containers from DB (TODO: implement fetch) + // For now, validate signatures conceptually + + // 2. Validate wiring: from.signature.can_wire_to(to.signature) + // This would use SchemaSignature::can_wire_to() method + + // 3. Check same canvas + // if from_container.canvas_id != to_container.canvas_id { + // return Err(WiringError::CrossCanvasConnection { ... }); + // } + + // 4. Create connection record + let connection = BlockConnection { + id: uuid::Uuid::new_v4().to_string(), + from_block: from_block_id.clone(), + to_block: to_block_id.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + }; + + // 5. Store connection in DB (TODO: add table) + // 6. Update container input_connections and output_connections vectors + + Ok(connection) +} + +/// Disconnect two block containers. +#[tauri::command] +pub async fn disconnect_blocks( + _from_block_id: String, + _to_block_id: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + // 1. Fetch connection from DB + // 2. Delete connection record + // 3. Update container vectors + Ok(()) +} + +#[cfg(test)] +mod tests { + use papillon_shared::SchemaSignature; + + #[test] + fn test_can_wire_place_to_weather() { + let from_sig = SchemaSignature { + input_types: vec![], + output_types: vec!["schema:Place".to_string()], + }; + let to_sig = SchemaSignature { + input_types: vec!["schema:Place".to_string()], + output_types: vec!["schema:WeatherForecast".to_string()], + }; + assert!(from_sig.can_wire_to(&to_sig)); + } + + #[test] + fn test_cannot_wire_incompatible_types() { + let from_sig = SchemaSignature { + input_types: vec![], + output_types: vec!["schema:WeatherForecast".to_string()], + }; + let to_sig = SchemaSignature { + input_types: vec!["schema:Place".to_string()], + output_types: vec!["schema:Event".to_string()], + }; + assert!(!from_sig.can_wire_to(&to_sig)); + } +} diff --git a/apps/papillon/src/lib.rs b/apps/papillon/src/lib.rs index d439fefd..57f18957 100644 --- a/apps/papillon/src/lib.rs +++ b/apps/papillon/src/lib.rs @@ -245,6 +245,9 @@ pub fn run() { commands::canvas::canvas_create_note, commands::canvas::canvas_update_note, commands::canvas::canvas_delete_note, + commands::canvas::create_block_container, + commands::canvas::connect_blocks, + commands::canvas::disconnect_blocks, commands::dataset_discovery::canvas_discover_datasets, commands::dataset_discovery::list_dataset_agents, commands::pipeline::run_pipeline, diff --git a/apps/registry/src/db/migrations/postgres/0004_add_credentials.sql b/apps/registry/src/db/migrations/postgres/0004_add_credentials.sql new file mode 100644 index 00000000..cab8f6d0 --- /dev/null +++ b/apps/registry/src/db/migrations/postgres/0004_add_credentials.sql @@ -0,0 +1,32 @@ +-- Principal credential vault: secrets, tokens, VCs, and attestations. +-- SECURITY NOTE: payload stores ciphertext. Encrypt secrets at the application layer +-- (e.g., with a key derived from a master secret) before insertion. Never log or +-- serialize the plaintext payload directly. +CREATE TABLE IF NOT EXISTS credentials ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'api_token', + payload TEXT NOT NULL DEFAULT '{}', + schema_type TEXT, + issuer_did TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_credentials_name ON credentials(name); + +-- Ensure updated_at refreshes on every row modification. +CREATE OR REPLACE FUNCTION refresh_credentials_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_credentials_updated_at ON credentials; +CREATE TRIGGER trg_credentials_updated_at + BEFORE UPDATE ON credentials + FOR EACH ROW + EXECUTE FUNCTION refresh_credentials_updated_at(); diff --git a/apps/registry/src/db/migrations/sqlite/0004_add_credentials.sql b/apps/registry/src/db/migrations/sqlite/0004_add_credentials.sql new file mode 100644 index 00000000..e767ffa6 --- /dev/null +++ b/apps/registry/src/db/migrations/sqlite/0004_add_credentials.sql @@ -0,0 +1,25 @@ +-- Principal credential vault: secrets, tokens, VCs, and attestations. +-- SECURITY NOTE: payload stores ciphertext. Encrypt secrets at the application layer +-- (e.g., with a key derived from a master secret) before insertion. Never log or +-- serialize the plaintext payload directly. +CREATE TABLE IF NOT EXISTS credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'api_token', + payload TEXT NOT NULL DEFAULT '{}', + schema_type TEXT, + issuer_did TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + expires_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_credentials_name ON credentials(name); + +-- Ensure updated_at refreshes on every row modification. +CREATE TRIGGER IF NOT EXISTS trg_credentials_updated_at +AFTER UPDATE ON credentials +FOR EACH ROW +BEGIN + UPDATE credentials SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = OLD.id; +END; diff --git a/apps/registry/src/db/mod.rs b/apps/registry/src/db/mod.rs index 930ba83e..b4dacf80 100644 --- a/apps/registry/src/db/mod.rs +++ b/apps/registry/src/db/mod.rs @@ -8,7 +8,7 @@ use sqlite::SqliteStore; use anyhow::Result; use chrono::{DateTime, Utc}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use pap_federation::peer::RegistryPeer; use pap_marketplace::AgentAdvertisement; @@ -33,6 +33,26 @@ pub struct AgentsPage { pub per_page: u32, } +#[derive(Debug, Clone, Serialize)] +pub struct CredentialEntry { + pub id: i64, + pub name: String, + pub kind: String, + pub payload: String, + pub schema_type: Option, + pub issuer_did: Option, + pub created_at: String, + pub updated_at: String, + pub expires_at: Option, +} + +pub struct CredentialsPage { + pub items: Vec, + pub total: u64, + pub page: u32, + pub per_page: u32, +} + // ── Enum-dispatch store ────────────────────────────────────────────────────── pub enum RegistryStore { @@ -165,4 +185,39 @@ impl RegistryStore { RegistryStore::Postgres(p) => p.save_setting(key, value).await, } } + + // ── Credentials ────────────────────────────────────────────────────────── + + pub async fn list_credentials( + &self, + q: Option<&str>, + page: u32, + per_page: u32, + ) -> Result { + match self { + RegistryStore::Sqlite(s) => s.list_credentials(q, page, per_page).await, + RegistryStore::Postgres(p) => p.list_credentials(q, page, per_page).await, + } + } + + pub async fn insert_credential(&self, entry: &CredentialEntry) -> Result { + match self { + RegistryStore::Sqlite(s) => s.insert_credential(entry).await, + RegistryStore::Postgres(p) => p.insert_credential(entry).await, + } + } + + pub async fn update_credential(&self, entry: &CredentialEntry) -> Result { + match self { + RegistryStore::Sqlite(s) => s.update_credential(entry).await, + RegistryStore::Postgres(p) => p.update_credential(entry).await, + } + } + + pub async fn delete_credential(&self, id: i64) -> Result { + match self { + RegistryStore::Sqlite(s) => s.delete_credential(id).await, + RegistryStore::Postgres(p) => p.delete_credential(id).await, + } + } } diff --git a/apps/registry/src/db/postgres.rs b/apps/registry/src/db/postgres.rs index 26321d7f..bdb51cf1 100644 --- a/apps/registry/src/db/postgres.rs +++ b/apps/registry/src/db/postgres.rs @@ -5,7 +5,7 @@ use sqlx::PgPool; use pap_federation::peer::RegistryPeer; use pap_marketplace::AgentAdvertisement; -use super::{AgentEntry, AgentsPage, NodeIdentity}; +use super::{AgentEntry, AgentsPage, CredentialEntry, CredentialsPage, NodeIdentity}; pub struct PostgresStore { pub pool: PgPool, @@ -339,4 +339,149 @@ impl PostgresStore { .await?; Ok(()) } + + // ── Credentials ──────────────────────────────────────────────────────────── + + pub async fn list_credentials( + &self, + q: Option<&str>, + page: u32, + per_page: u32, + ) -> Result { + let offset = page.saturating_sub(1) * per_page; + + let (total, rows): ( + u64, + Vec<( + i64, + String, + String, + String, + Option, + Option, + String, + String, + Option, + )>, + ) = if let Some(query) = q.filter(|s| !s.is_empty()) { + let pattern = format!("%{query}%"); + let total: i64 = + sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM credentials WHERE name LIKE $1") + .bind(&pattern) + .fetch_one(&self.pool) + .await + .map(|(n,)| n) + .unwrap_or(0); + + let rows = sqlx::query_as::<_, (i64, String, String, String, Option, Option, String, String, Option)>( + "SELECT id, name, kind, payload, schema_type, issuer_did, created_at, updated_at, expires_at + FROM credentials WHERE name LIKE $1 + ORDER BY updated_at DESC LIMIT $2 OFFSET $3", + ) + .bind(&pattern) + .bind(per_page as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await?; + + (total as u64, rows) + } else { + let total: i64 = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM credentials") + .fetch_one(&self.pool) + .await + .map(|(n,)| n) + .unwrap_or(0); + + let rows = sqlx::query_as::<_, (i64, String, String, String, Option, Option, String, String, Option)>( + "SELECT id, name, kind, payload, schema_type, issuer_did, created_at, updated_at, expires_at + FROM credentials + ORDER BY updated_at DESC LIMIT $1 OFFSET $2", + ) + .bind(per_page as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await?; + + (total as u64, rows) + }; + + let items = rows + .into_iter() + .map( + |( + id, + name, + kind, + payload, + schema_type, + issuer_did, + created_at, + updated_at, + expires_at, + )| { + CredentialEntry { + id, + name, + kind, + payload, + schema_type, + issuer_did, + created_at, + updated_at, + expires_at, + } + }, + ) + .collect(); + + Ok(CredentialsPage { + items, + total, + page, + per_page, + }) + } + + pub async fn insert_credential(&self, entry: &CredentialEntry) -> Result { + let id: i64 = sqlx::query_scalar( + "INSERT INTO credentials (name, kind, payload, schema_type, issuer_did, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id", + ) + .bind(&entry.name) + .bind(&entry.kind) + .bind(&entry.payload) + .bind(&entry.schema_type) + .bind(&entry.issuer_did) + .bind(&entry.expires_at) + .fetch_one(&self.pool) + .await?; + Ok(id) + } + + pub async fn update_credential(&self, entry: &CredentialEntry) -> Result { + let result = sqlx::query( + "UPDATE credentials SET + name = $1, kind = $2, payload = $3, schema_type = $4, issuer_did = $5, expires_at = $6 + WHERE id = $7", + ) + .bind(&entry.name) + .bind(&entry.kind) + .bind(&entry.payload) + .bind(&entry.schema_type) + .bind(&entry.issuer_did) + .bind(&entry.expires_at) + .bind(entry.id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn delete_credential(&self, id: i64) -> Result { + let result = sqlx::query("DELETE FROM credentials WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } } diff --git a/apps/registry/src/db/sqlite.rs b/apps/registry/src/db/sqlite.rs index 22902169..fdf26f74 100644 --- a/apps/registry/src/db/sqlite.rs +++ b/apps/registry/src/db/sqlite.rs @@ -5,7 +5,7 @@ use sqlx::SqlitePool; use pap_federation::peer::RegistryPeer; use pap_marketplace::AgentAdvertisement; -use super::{AgentEntry, AgentsPage, NodeIdentity}; +use super::{AgentEntry, AgentsPage, CredentialEntry, CredentialsPage, NodeIdentity}; pub struct SqliteStore { pub pool: SqlitePool, @@ -327,6 +327,150 @@ impl SqliteStore { .await?; Ok(()) } + + // ── Credentials ──────────────────────────────────────────────────────────── + + pub async fn list_credentials( + &self, + q: Option<&str>, + page: u32, + per_page: u32, + ) -> Result { + let offset = page.saturating_sub(1) * per_page; + + let (total, rows): ( + u64, + Vec<( + i64, + String, + String, + String, + Option, + Option, + String, + String, + Option, + )>, + ) = if let Some(query) = q.filter(|s| !s.is_empty()) { + let pattern = format!("%{query}%"); + let total: i64 = + sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM credentials WHERE name LIKE ?") + .bind(&pattern) + .fetch_one(&self.pool) + .await + .map(|(n,)| n) + .unwrap_or(0); + + let rows = sqlx::query_as::<_, (i64, String, String, String, Option, Option, String, String, Option)>( + "SELECT id, name, kind, payload, schema_type, issuer_did, created_at, updated_at, expires_at + FROM credentials WHERE name LIKE ? + ORDER BY updated_at DESC LIMIT ? OFFSET ?", + ) + .bind(&pattern) + .bind(per_page as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await?; + + (total as u64, rows) + } else { + let total: i64 = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM credentials") + .fetch_one(&self.pool) + .await + .map(|(n,)| n) + .unwrap_or(0); + + let rows = sqlx::query_as::<_, (i64, String, String, String, Option, Option, String, String, Option)>( + "SELECT id, name, kind, payload, schema_type, issuer_did, created_at, updated_at, expires_at + FROM credentials + ORDER BY updated_at DESC LIMIT ? OFFSET ?", + ) + .bind(per_page as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await?; + + (total as u64, rows) + }; + + let items = rows + .into_iter() + .map( + |( + id, + name, + kind, + payload, + schema_type, + issuer_did, + created_at, + updated_at, + expires_at, + )| { + CredentialEntry { + id, + name, + kind, + payload, + schema_type, + issuer_did, + created_at, + updated_at, + expires_at, + } + }, + ) + .collect(); + + Ok(CredentialsPage { + items, + total, + page, + per_page, + }) + } + + pub async fn insert_credential(&self, entry: &CredentialEntry) -> Result { + let result = sqlx::query( + "INSERT INTO credentials (name, kind, payload, schema_type, issuer_did, expires_at) + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&entry.name) + .bind(&entry.kind) + .bind(&entry.payload) + .bind(&entry.schema_type) + .bind(&entry.issuer_did) + .bind(&entry.expires_at) + .execute(&self.pool) + .await?; + Ok(result.last_insert_rowid()) + } + + pub async fn update_credential(&self, entry: &CredentialEntry) -> Result { + let result = sqlx::query( + "UPDATE credentials SET + name = ?, kind = ?, payload = ?, schema_type = ?, issuer_did = ?, expires_at = ? + WHERE id = ?", + ) + .bind(&entry.name) + .bind(&entry.kind) + .bind(&entry.payload) + .bind(&entry.schema_type) + .bind(&entry.issuer_did) + .bind(&entry.expires_at) + .bind(entry.id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn delete_credential(&self, id: i64) -> Result { + let result = sqlx::query("DELETE FROM credentials WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } } #[cfg(test)] diff --git a/apps/registry/src/ui/pages/agents.rs b/apps/registry/src/ui/pages/agents.rs index d382f05d..fdbccf4c 100644 --- a/apps/registry/src/ui/pages/agents.rs +++ b/apps/registry/src/ui/pages/agents.rs @@ -99,8 +99,8 @@ pub fn AgentsPage() -> impl IntoView {