diff --git a/README.md b/README.md index 0fceed143..d4c512080 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A web-based dashboard for monitoring and interacting with [pi](https://github.co - **Bidirectional interaction** — Send prompts and commands from the browser - **Workspace management** — Organize sessions by project folder with pinned directories and drag-to-reorder - **Command autocomplete** — `/` prefix triggers command dropdown with filtering +- **Schema-driven extension UI** — Extensions can expose modal management UIs and lightweight status-bar controls from shared schema, without dashboard-specific React work - **Session statistics** — Token counts, costs, model info, thinking level, context usage bar - **Elapsed time tracking** — Live ticking counters on running operations, final duration on completed tool calls and reasoning blocks - **Mobile-friendly** — Responsive layout with swipe drawer, touch targets, and mobile action menus @@ -70,10 +71,11 @@ The system has three components: |-----------|----------|------| | **Bridge Extension** | `packages/extension/` | Runs in every pi session. Forwards events, relays commands, auto-starts server, hosts PromptBus. | | **Dashboard Server** | `packages/server/` | Aggregates events in-memory, persists metadata to JSON, serves the web client, manages terminals. | -| **Web Client** | `packages/client/` | React + Tailwind UI with real-time WebSocket updates. | +| **Web Client** | `packages/client/` | React + Tailwind UI with real-time WebSocket updates and generic extension-driven modal/status-bar rendering. | | **Shared** | `packages/shared/` | TypeScript types, protocols, and utilities shared across all packages. | See [docs/architecture.md](docs/architecture.md) for detailed data flows, reconnection logic, and persistence model. +See [docs/extension-ui-system.md](docs/extension-ui-system.md) for the generalized extension schema reference. ## Getting Started diff --git a/docs/extension-ui-system.md b/docs/extension-ui-system.md new file mode 100644 index 000000000..e6e70f315 --- /dev/null +++ b/docs/extension-ui-system.md @@ -0,0 +1,144 @@ +# Generalized Extension UI System + +The **Generalized Extension UI System** (also known as the **Hybrid Schema**) allows any `pi` extension to define and render interactive management menus directly in the Web Dashboard without requiring project-specific patches or new React components in the dashboard codebase. + +## Architecture + +The system follows a metadata-driven, event-based flow: + +1. **Discovery**: When a session starts, the Dashboard Bridge queries all active extensions for their UI module definitions using the `ui:list-modules` event. +2. **Schema Registration**: Extensions respond by providing a JSON schema (`ExtensionUiModule`) describing their views (tables, forms), fields, and actions. +3. **Caching**: The Dashboard Server caches these schemas in the session object, ensuring they are available to any browser that connects or refreshes. +4. **Interception**: When a user types a command in the dashboard (e.g., `/schedule`), the frontend checks if that command matches a registered UI module. +5. **Rendering**: If matched, the dashboard opens a `GenericExtensionDialog` which dynamically renders the UI based on the schema. +6. **Data Flow**: Data for tables is fetched via a unified `ui:get-data` protocol, and actions (like "Add" or "Delete") are forwarded back to the extension via `pi.events`. + +--- + +## Extension Implementation Guide + +To add a dashboard UI to your extension, follow these steps: + +### 1. Register the UI Module +Listen for the `ui:list-modules` event and push your schema to the `data.modules` array. + +```typescript +pi.events.on("ui:list-modules", (data: any) => { + data.modules.push({ + id: "my-extension", + title: "My Extension Manager", + icon: "cogOutline", // MDI icon name (camelCase, e.g. mdiCogOutline -> cogOutline) + command: "/my-command", // The slash command that triggers this modal + initialViewId: "list", + views: [ + { + id: "list", + type: "table", + title: "Items", + dataEvent: "my-ext:list-items", // Key used for fetching data + updateEvent: "my-ext:change", // Key that triggers auto-refresh + fields: [ + { key: "name", label: "Name", type: "text" }, + { key: "active", label: "Active", type: "boolean" } + ], + itemActions: [ + { label: "Delete", icon: "trashCan", emit: "my-ext:delete-request", primaryParam: "id", variant: "danger", confirm: "Delete?" } + ], + actions: [ + { label: "New Item", icon: "plus", emit: "ui:navigate", params: { viewId: "add" }, variant: "primary" } + ] + }, + { + id: "add", + type: "form", + title: "Add Item", + fields: [ + { key: "name", label: "Item Name", type: "text", required: true } + ], + actions: [ + { label: "Cancel", emit: "ui:navigate", params: { viewId: "list" } }, + { label: "Save", emit: "my-ext:add-request", variant: "primary" } + ] + } + ] + }); +}); +``` + +### 2. Implement Data Fetching +Listen for the `ui:get-data` event. This event is emitted by the dashboard whenever a view is opened or refreshed. + +```typescript +pi.events.on("ui:get-data", (data: any) => { + if (data.event === "my-ext:list-items") { + data.items = myStorage.getAll(); // Return an array of objects + } +}); +``` + +### 3. Handle Actions & Notifications +Implement your logic for add/delete/toggle events. Use `flow:notify` to send feedback back to the dashboard. + +```typescript +pi.events.on("my-ext:add-request", (params: any) => { + try { + myStorage.add(params); // params contains all form fields + pi.events.emit("my-ext:change", {}); // Trigger refresh + pi.events.emit("flow:notify", { message: "Saved!", level: "success" }); + } catch (e) { + pi.events.emit("flow:notify", { message: e.message, level: "error" }); + } +}); +``` + +--- + +## UI Schema Reference + +### `ExtensionUiModule` +| Property | Type | Description | +| :--- | :--- | :--- | +| `id` | `string` | Unique identifier for the module. | +| `title` | `string` | Display name in the modal header. | +| `icon` | `string` | MDI icon name (e.g., `clockOutline`). | +| `command` | `string` | Slash command (including `/`) that triggers the UI. | +| `views` | `UiView[]` | Array of view definitions. | +| `initialViewId` | `string` | The ID of the view to show first. | + +### `UiView` +| Property | Type | Description | +| :--- | :--- | :--- | +| `id` | `string` | Unique identifier for the view. | +| `type` | `"table" \| "form"` | Layout type. | +| `dataEvent` | `string` | (Table only) The event key used in `ui:get-data`. | +| `updateEvent` | `string` | (Table only) Event name that triggers an automatic data refresh. | +| `fields` | `UiField[]` | Columns for tables or inputs for forms. | +| `actions` | `UiAction[]` | Buttons at the top (tables) or bottom (forms). | +| `itemActions` | `UiAction[]` | (Table only) Action buttons for every row. | + +### `UiAction` +| Property | Type | Description | +| :--- | :--- | :--- | +| `label` | `string` | Button text or tooltip. | +| `emit` | `string` | Event name to emit on click. Use `ui:navigate` for internal navigation. | +| `params` | `object` | Static parameters to send with the event. | +| `primaryParam` | `string` | (Table rows only) Key from the row object to include in the payload. | +| `variant` | `string` | `primary`, `secondary`, `danger`, `warning`, `success`. | +| `confirm` | `string` | If set, shows a browser confirmation dialog before emitting. | + +### `UiField` +| Property | Type | Description | +| :--- | :--- | :--- | +| `key` | `string` | Property name in the data object. | +| `label` | `string` | Display label. | +| `type` | `string` | `text`, `number`, `boolean`, `select`, `code`, `textarea`. | +| `options` | `object[]` | (Select only) Array of `{ label, value }`. | + +--- + +## Protocol Details (WebSocket) + +- **`ui_management`**: Sent by Browser -> Server -> Extension. Used to trigger actions or request data. +- **`ui_data_list`**: Sent by Extension -> Server -> Browser. Contains the items array for a table. +- **`ui_modules_list`**: Sent by Extension -> Server -> Browser. Contains the UI module schemas. +- **`flow:notify`**: Forwarded Event. Triggers a Toast notification in the browser. diff --git a/docs/plans/ragger-dashboard-integration.md b/docs/plans/ragger-dashboard-integration.md new file mode 100644 index 000000000..6ba27dc98 --- /dev/null +++ b/docs/plans/ragger-dashboard-integration.md @@ -0,0 +1,525 @@ +# Ragger Dashboard Integration — Implementation Plan + +Status: partially implemented and updated to reflect the shipped schema direction. + +## Goal + +Expose ragger's local RAG data (workspaces, search, indexing, stats) to the pi-agent-dashboard web UI, using a **hybrid approach**: + +1. **Extension UI System** (schema-driven `GenericExtensionDialog`) for workspace CRUD management +2. **Server-side REST proxy** for search and file-level operations that need richer rendering +3. **Schema extensions** to the Generalized Extension UI System so future extensions can build richer UIs without dashboard code changes +4. **Status-bar projection** so extensions can surface lightweight session-aware controls outside the modal + +--- + +## Repositories Affected + +| Repo | Role | Scope | +|------|------|-------| +| `pi-agent-dashboard` | Web dashboard monorepo | Server proxy, schema extensions, new view renderers, shared types | +| `pi-ragger` | pi extension | Add `ui:list-modules` / `ui:get-data` / action handlers | +| `ragger` (Python) | RAG backend | Added supporting API endpoints such as workspace file listing for the dashboard integration | + +--- + +## Phase 0: Extend the Generalized Extension UI Schema + +> **Design principle**: Every new view type and field type we add must be generic enough for any future extension to use, not just ragger. + +### 0.1 New View Types in `UiView` + +Current: `"table" | "grid" | "form"` + +Add: + +```typescript +export interface UiView { + id: string; + type: "table" | "grid" | "form" | "search" | "detail" | "metrics"; + title?: string; + dataEvent?: string; + updateEvent?: string; + fields?: UiField[]; + actions?: UiAction[]; + itemActions?: UiAction[]; + + // NEW: search view config + searchConfig?: { + placeholder?: string; + queryParam?: string; // param name for search query (default: "query") + resultDataEvent?: string; // dataEvent for search results + resultFields?: UiField[]; // fields to display in results + resultActions?: UiAction[]; // actions per result row + debounceMs?: number; // default 300 + }; + + // NEW: detail view config + detailConfig?: { + sections?: UiDetailSection[]; + }; + + // NEW: metrics view config + metricsConfig?: { + cards?: UiMetricCard[]; + }; +} +``` + +### 0.2 New View Type: `search` + +A search view provides: +- A search input with debounce +- Real-time results rendered as a list/table +- Configurable result fields and actions + +**Use cases**: Ragger search, log search, file search, any extension that needs query → results flow. + +**Rendering** (`GenericExtensionDialog`): +``` +┌─────────────────────────────────────┐ +│ 🔍 [search input____________] [Go] │ +├─────────────────────────────────────┤ +│ Result 1: path/to/file.ts │ +│ preview text here... │ +│ [Open] [Copy] │ +├─────────────────────────────────────┤ +│ Result 2: path/other.py │ +│ preview text here... │ +│ [Open] [Copy] │ +└─────────────────────────────────────┘ +``` + +**Data flow**: +1. User types query → debounced `ui:get-data` with `{ event: resultDataEvent, query }` +2. Extension populates `data.items` with results +3. Results rendered using `resultFields` + `resultActions` + +### 0.3 New View Type: `metrics` + +A metrics view displays stat cards in a grid: + +```typescript +interface UiMetricCard { + key: string; // data key to read value from + label: string; + icon?: string; + format?: "number" | "bytes" | "percent" | "duration" | "text"; + color?: string; // accent color hint + suffix?: string; // e.g. "files", "chunks" +} +``` + +**Rendering**: +``` +┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ +│ 📁 142 │ │ 📄 1,847 │ │ 🕐 2m ago │ │ 🤖 nomic │ +│ Files │ │ Chunks │ │ Last Index │ │ Embedding │ +└────────────┘ └────────────┘ └────────────┘ └────────────┘ +``` + +**Data flow**: Same `dataEvent` pattern as table — extension returns a single object (not array) with keys matching metric card `key` values. + +### 0.4 New View Type: `detail` + +A detail view renders sections of key-value pairs for a single item: + +```typescript +interface UiDetailSection { + title?: string; + fields: UiField[]; +} +``` + +Useful for "click into a row" drill-down without needing a custom component. + +### 0.5 New Field Types + +Current: `"text" | "number" | "boolean" | "select" | "code" | "datetime" | "textarea"` + +Add: + +| Type | Rendering | Use Case | +|------|-----------|----------| +| `"badge"` | Colored pill/tag | File extensions, status labels | +| `"progress"` | Progress bar (0-100) | Indexing progress | +| `"snippet"` | Code block with syntax highlight | Search result previews | +| `"link"` | Clickable link/path | File paths, URLs | +| `"bytes"` | Human-readable size | File sizes | + +### 0.6 New Action Properties + +```typescript +interface UiAction { + // ... existing ... + loading?: boolean; // Show spinner on button + refreshAfter?: boolean; // Auto-refresh parent view after action completes + navigateTo?: string; // Navigate to viewId after success (alternative to ui:navigate emit) +} +``` + +### 0.7 Files Changed (Phase 0) + +| File | Change | +|------|--------| +| `packages/shared/src/types.ts` | Add `searchConfig`, `detailConfig`, `metricsConfig`, new field types, `UiMetricCard`, `UiDetailSection` | +| `packages/client/src/components/GenericExtensionDialog.tsx` | Add `renderSearch`, `renderMetrics`, `renderDetail` methods + new field renderers | +| `docs/extension-ui-system.md` | Document new view types and field types | + +--- + +## Phase 1: pi-ragger Extension UI Registration + +Enhance `pi-ragger/index.ts` to register dashboard UI modules. + +### 1.1 Module: Ragger Manager (workspace CRUD) + +Triggered by `/ragger` command. Views: + +**View: `status` (metrics)** +``` +Cards: Connection Status | Workspaces | Embedding Model | Chat Model +``` +Data event: `ragger:status` +Data: `{ connected: bool, workspaceCount: number, embeddingModel: string, model: string }` + +**View: `workspaces` (table)** +``` +| Name | Files | Chunks | Extensions | Last Indexed | Actions | +|------|-------|--------|------------|-------------|---------| +| default | 142 | 1847 | .ts,.py,.md | 2m ago | [Re-index] [Search] [Delete] | +``` +Data event: `ragger:list-workspaces` +Update event: `ragger:change` + +**View: `index` (form)** +``` +Workspace Name: [________] +Path: [________] +Replace: [✓] +[Cancel] [Index] +``` + +**View: `search` (search)** +``` +🔍 [search ragger workspace_____________] +→ results with snippet previews, path, score +``` +Result data event: `ragger:search` +Result fields: `relative_path`, `score`, `language`, `content_preview` + +### 1.2 Event Handlers + +```typescript +// Data providers +pi.events.on("ui:get-data", async (data) => { + if (data.event === "ragger:status") → fetch /health + if (data.event === "ragger:list-workspaces") → fetch /workspaces + if (data.event === "ragger:search") → fetch /workspaces/search +}); + +// Action handlers +pi.events.on("ragger:index-request", ...) → POST /workspaces/index +pi.events.on("ragger:delete-request", ...) → DELETE /workspaces/:name +pi.events.on("ragger:search-request", ...) // handled by search view data flow +``` + +### 1.3 Files Changed (Phase 1) + +| File | Change | +|------|--------| +| `pi-ragger/index.ts` | Add `ui:list-modules`, `ui:get-data`, action handlers (~150 lines) | + +--- + +## Phase 2: Server-Side Ragger Proxy + +For operations that benefit from server-side caching or don't go through the extension (e.g., polling, direct browser access): + +### 2.1 Ragger Client (`packages/server/src/ragger/ragger-client.ts`) + +```typescript +class RaggerClient { + private baseUrl: string; + + async getHealth(): Promise + async listWorkspaces(): Promise + async getWorkspaceStats(name: string): Promise + async indexWorkspace(name: string, path: string, replace: boolean): Promise + async search(name: string, query: string, k: number): Promise + async deleteWorkspace(name: string): Promise + async listFiles(name: string): Promise +} +``` + +Reads `ragger.baseUrl` from dashboard config (`~/.pi/dashboard/config.json`): +```json +{ + "ragger": { + "enabled": true, + "baseUrl": "http://127.0.0.1:8170", + "pollIntervalMs": 30000 + } +} +``` + +### 2.2 Ragger Poller (`packages/server/src/ragger/ragger-poller.ts`) + +Periodic polling (like OpenSpec poller) that: +- Checks ragger server health +- Caches workspace list + stats +- Broadcasts `ragger_status_update` to subscribed browsers when data changes + +### 2.3 REST Routes (`packages/server/src/routes/ragger-routes.ts`) + +All localhost-guarded: + +``` +GET /api/ragger/status → cached health + workspace summary +GET /api/ragger/workspaces → list all workspaces with stats +GET /api/ragger/workspaces/:name → single workspace details +GET /api/ragger/workspaces/:name/files → indexed file manifest +POST /api/ragger/workspaces/index → trigger indexing (proxied) +POST /api/ragger/workspaces/search → proxy search +DELETE /api/ragger/workspaces/:name → delete workspace (proxied) +``` + +### 2.4 WebSocket Broadcast + +```typescript +// browser-protocol.ts additions +interface RaggerStatusUpdateMessage { + type: "ragger_status_update"; + connected: boolean; + workspaces: RaggerWorkspace[]; +} +``` + +### 2.5 Files Created (Phase 2) + +| File | Purpose | +|------|---------| +| `packages/server/src/ragger/ragger-client.ts` | HTTP client for ragger FastAPI | +| `packages/server/src/ragger/ragger-poller.ts` | Periodic polling + change detection | +| `packages/server/src/routes/ragger-routes.ts` | REST endpoints | + +### 2.6 Files Modified (Phase 2) + +| File | Change | +|------|--------| +| `packages/server/src/server.ts` | Register ragger service + routes | +| `packages/shared/src/browser-protocol.ts` | Add `ragger_status_update` message type | +| `packages/shared/src/types.ts` | Add `RaggerWorkspace`, `RaggerHealth`, etc. | +| `docs/architecture.md` | Document ragger proxy data flow | + +--- + +## Phase 3: Dashboard Client Components + +### 3.1 Ragger Status Badge (sidebar) + +A small indicator in the session sidebar (similar to OpenSpec badge): +- Green dot + "RAG" when connected +- Red dot when disconnected +- Click → opens ragger management dialog (via `/ragger` command → GenericExtensionDialog) + +This is **automatic** — no new component needed. The Extension UI System handles it once `ui:list-modules` registers the `/ragger` command. + +### 3.2 Server-Proxy Search (optional enhancement) + +If we want a richer search experience than what `GenericExtensionDialog` provides (e.g., full-width search panel with syntax-highlighted snippets), we add: + +| File | Purpose | +|------|---------| +| `packages/client/src/components/RaggerSearchPanel.tsx` | Full search panel using server proxy | +| `packages/client/src/hooks/useRagger.ts` | Hook for ragger API calls via server proxy | + +This is **Phase 3b** — optional, only if the schema-driven search view isn't sufficient. + +--- + +## Implementation Order + +``` +Phase 0 (schema extensions) + ├─ 0.1 Add types to shared/src/types.ts + ├─ 0.2 Implement renderSearch in GenericExtensionDialog + ├─ 0.3 Implement renderMetrics in GenericExtensionDialog + ├─ 0.4 Implement renderDetail in GenericExtensionDialog + ├─ 0.5 Add new field type renderers (badge, progress, snippet, link, bytes) + └─ 0.6 Update docs/extension-ui-system.md + +Phase 1 (pi-ragger extension) + ├─ 1.1 Add ui:list-modules handler with all 4 views + ├─ 1.2 Add ui:get-data handlers (health, workspaces, search) + └─ 1.3 Add action handlers (index, delete) + +Phase 2 (server proxy) + ├─ 2.1 RaggerClient HTTP wrapper + ├─ 2.2 Ragger poller (health + workspace cache) + ├─ 2.3 REST routes + └─ 2.4 Wire into server.ts + browser protocol + +Phase 3 (client enhancements — optional) + ├─ 3.1 Sidebar badge (automatic via Phase 1) + └─ 3.2 Full search panel (if schema search isn't enough) +``` + +### Testing Strategy + +| Phase | How to test | +|-------|-------------| +| 0 | Unit tests for new view renderers with mock schemas; visual test with pi-scheduler adding a metrics view | +| 1 | `pi -e pi-ragger/index.ts` → `/ragger` in dashboard → verify modal renders | +| 2 | Start ragger-server → `curl /api/ragger/status` → verify proxy | +| 3 | Full E2E: ragger-server + pi + dashboard browser → search from UI | + +--- + +## Ragger Schema Registration (Pseudocode) + +Here's the complete `ui:list-modules` registration that pi-ragger will use: + +```typescript +pi.events.on("ui:list-modules", (data: any) => { + data.modules.push({ + id: "ragger", + title: "Ragger — Local RAG", + icon: "databaseSearch", + command: "/ragger", + initialViewId: "status", + views: [ + // ── Metrics overview ── + { + id: "status", + type: "metrics", + title: "Overview", + dataEvent: "ragger:status", + metricsConfig: { + cards: [ + { key: "connected", label: "Status", icon: "lanConnect", format: "text" }, + { key: "workspaceCount", label: "Workspaces", icon: "folderMultiple", format: "number" }, + { key: "embeddingModel", label: "Embeddings", icon: "brain", format: "text" }, + { key: "model", label: "Chat Model", icon: "robotOutline", format: "text" }, + ] + }, + actions: [ + { label: "Workspaces", icon: "folderMultiple", emit: "ui:navigate", params: { viewId: "workspaces" } }, + { label: "Search", icon: "magnify", emit: "ui:navigate", params: { viewId: "search" } }, + ] + }, + // ── Workspace table ── + { + id: "workspaces", + type: "table", + title: "Workspaces", + dataEvent: "ragger:list-workspaces", + updateEvent: "ragger:change", + fields: [ + { key: "workspace", label: "Name", type: "text" }, + { key: "file_count", label: "Files", type: "number" }, + { key: "chunk_count", label: "Chunks", type: "number" }, + { key: "indexed_extensions", label: "Extensions", type: "badge" }, + { key: "last_indexed_at", label: "Last Indexed", type: "datetime" }, + ], + itemActions: [ + { label: "Re-index", icon: "refresh", emit: "ragger:reindex-request", primaryParam: "workspace" }, + { label: "Search", icon: "magnify", emit: "ragger:navigate-search", primaryParam: "workspace" }, + { label: "Files", icon: "fileMultiple", emit: "ragger:navigate-files", primaryParam: "workspace" }, + { label: "Delete", icon: "trashCanOutline", emit: "ragger:delete-request", primaryParam: "workspace", variant: "danger", confirm: "Delete this workspace?" }, + ], + actions: [ + { label: "Index New", icon: "plus", emit: "ui:navigate", params: { viewId: "index" }, variant: "primary" }, + ] + }, + // ── Index form ── + { + id: "index", + type: "form", + title: "Index Workspace", + fields: [ + { key: "workspace", label: "Workspace Name", type: "text", required: true, placeholder: "default" }, + { key: "path", label: "Path to Index", type: "text", required: true, placeholder: "/path/to/codebase" }, + { key: "replace", label: "Replace Existing", type: "select", options: [ + { label: "Yes — full reindex", value: true }, + { label: "No — incremental", value: false }, + ] }, + ], + actions: [ + { label: "Cancel", emit: "ui:navigate", params: { viewId: "workspaces" } }, + { label: "Start Indexing", icon: "database", emit: "ragger:index-request", variant: "primary" }, + ] + }, + // ── Search ── + { + id: "search", + type: "search", + title: "Search", + searchConfig: { + placeholder: "Search indexed code...", + resultDataEvent: "ragger:search", + resultFields: [ + { key: "relative_path", label: "File", type: "link" }, + { key: "score", label: "Score", type: "number" }, + { key: "language", label: "Language", type: "badge" }, + { key: "content_preview", label: "Preview", type: "snippet" }, + ], + resultActions: [ + { label: "Copy Path", icon: "contentCopy", emit: "ragger:copy-path", primaryParam: "relative_path" }, + ], + debounceMs: 300, + } + }, + // ── File list (detail/table for a workspace) ── + { + id: "files", + type: "table", + title: "Indexed Files", + dataEvent: "ragger:list-files", + fields: [ + { key: "relative_path", label: "Path", type: "link" }, + { key: "extension", label: "Type", type: "badge" }, + { key: "language", label: "Language", type: "text" }, + { key: "chunk_count", label: "Chunks", type: "number" }, + ], + actions: [ + { label: "Back", icon: "arrowLeft", emit: "ui:navigate", params: { viewId: "workspaces" } }, + ] + }, + ] + }); +}); +``` + +--- + +## Summary: New vs Modified Files + +### New Files (8) + +| # | File | Phase | +|---|------|-------| +| 1 | `packages/server/src/ragger/ragger-client.ts` | 2 | +| 2 | `packages/server/src/ragger/ragger-poller.ts` | 2 | +| 3 | `packages/server/src/routes/ragger-routes.ts` | 2 | + +### Modified Files (7) + +| # | File | Phase | Change | +|---|------|-------|--------| +| 1 | `packages/shared/src/types.ts` | 0 | New view types, field types, metric/detail configs | +| 2 | `packages/client/src/components/GenericExtensionDialog.tsx` | 0 | renderSearch, renderMetrics, renderDetail, new field renderers | +| 3 | `docs/extension-ui-system.md` | 0 | Document new schema features | +| 4 | `pi-ragger/index.ts` | 1 | Add ui:list-modules, ui:get-data, action handlers | +| 5 | `packages/server/src/server.ts` | 2 | Register ragger service + routes | +| 6 | `packages/shared/src/browser-protocol.ts` | 2 | Add ragger_status_update message | +| 7 | `docs/architecture.md` | 2 | Document ragger proxy flow | + +### Optional Files (Phase 3b) + +| # | File | Purpose | +|---|------|---------| +| 1 | `packages/client/src/components/RaggerSearchPanel.tsx` | Full-width search panel | +| 2 | `packages/client/src/hooks/useRagger.ts` | Hook for ragger REST API | + +**Total: 3 new files, 7 modified files.** (down from 10+10 in the original plan thanks to the schema-driven approach) diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 3941ef2c9..4ca5b1368 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -44,9 +44,11 @@ import { useContentViews } from "./hooks/useContentViews.js"; import { useSessionActions } from "./hooks/useSessionActions.js"; import { usePendingPromptTimeout } from "./hooks/usePendingPromptTimeout.js"; import { useOpenSpecActions } from "./hooks/useOpenSpecActions.js"; -import type { DashboardSession, CommandInfo, FlowInfo, FileEntry, OpenSpecData, ModelInfo, RoleInfo, ImageContent } from "@blackbelt-technology/pi-dashboard-shared/types.js"; +import type { DashboardSession, CommandInfo, FlowInfo, FileEntry, OpenSpecData, ModelInfo, RoleInfo, ImageContent, CronJob, ExtensionUiModule } from "@blackbelt-technology/pi-dashboard-shared/types.js"; import { SearchableSelectDialog, type SelectOption } from "./components/SearchableSelectDialog.js"; import { FlowLaunchDialog } from "./components/FlowLaunchDialog.js"; +import { GenericExtensionDialog } from "./components/GenericExtensionDialog.js"; +import { Toast, useToast } from "./components/Toast.js"; import { PinDirectoryDialog } from "./components/PinDirectoryDialog.js"; import { DialogPortal } from "./components/DialogPortal.js"; import { useProvidersReady } from "./hooks/useProvidersReady.js"; @@ -135,7 +137,8 @@ export default function App() { const [sessions, setSessions] = useState>(new Map()); const [sessionStates, setSessionStates] = useState>(new Map()); const [sessionCommands, setSessionCommands] = useState>(new Map()); - const [sessionFlows, setSessionFlows] = useState>(new Map()); + const [sessionUiModules, setSessionUiModules] = useState>(new Map()); + const [sessionUiData, setSessionUiData] = useState>>(new Map()); const [fileResults, setFileResults] = useState<{ query: string; files: FileEntry[] } | null>(null); const [openspecMap, setOpenspecMap] = useState>(new Map()); const [modelsMap, setModelsMap] = useState>(new Map()); @@ -214,7 +217,8 @@ export default function App() { setSessions(new Map()); setSessionStates(new Map()); setSessionCommands(new Map()); - setSessionFlows(new Map()); + setSessionUiModules(new Map()); + setSessionUiData(new Map()); setOpenspecMap(new Map()); setTerminals(new Map()); subscribedRef.current.clear(); @@ -249,8 +253,10 @@ export default function App() { } }, []); + const { messages, showToast, dismissToast } = useToast(); + const handleMessage = useMessageHandler( - { setSessions, setSessionStates, setSessionCommands, setSessionFlows, setFileResults, setOpenspecMap, setModelsMap, setRolesMap, setSpawnResult, setSessionOrderMap, setPinnedDirectories, setTerminals, setEditorStatuses, setDiscoveredServers, setSpawnErrors, setResumeErrors }, + { setSessions, setSessionStates, setSessionCommands, setSessionUiModules, setSessionUiData, setFileResults, setOpenspecMap, setModelsMap, setRolesMap, setSpawnResult, setSessionOrderMap, setPinnedDirectories, setTerminals, setEditorStatuses, setDiscoveredServers, setSpawnErrors, setResumeErrors, showToast }, { send, navigate, clearSpawningCwd, spawningCwdsRef, subscribedRef, pendingTerminalCwdRef, lastCreatedTerminalIdRef, maxSeqMapRef, selectedSessionIdRef }, ); @@ -350,7 +356,7 @@ export default function App() { : []; const selectedFlows = selectedId - ? sessionFlows.get(selectedId) ?? [] + ? (sessionUiData.get(selectedId)?.get("flow:list-flows") ?? []) : []; const selectedSession = selectedId ? sessions.get(selectedId) : undefined; @@ -401,10 +407,46 @@ export default function App() { const [flowDeletePickerOpen, setFlowDeletePickerOpen] = useState(false); const [flowDeleteFlowName, setFlowDeleteFlowName] = useState(null); const [flowLaunchTarget, setFlowLaunchTarget] = useState(null); + const [genericModuleOpen, setGenericModuleOpen] = useState(null); // Wrap handleSend to intercept /flows commands const wrappedHandleSend = useCallback((text: string, images?: ImageContent[]) => { const trimmed = text.trim(); + console.log(`[App] Intercepting: "${trimmed}", selectedId: ${selectedId}`); + + // Dynamic Module Interception (Generalized UI) + if (selectedId) { + const modules = sessionUiModules.get(selectedId) || []; + console.log(`[App] Modules for session ${selectedId}:`, modules.map(m => m.command)); + + const match = modules.find(m => m.command === trimmed); + + if (match) { + console.log(`[App] Matched dynamic module "${match.id}" for command "${trimmed}"`); + // Request fresh data when opening + if (match.initialViewId) { + const initialView = match.views.find(v => v.id === match.initialViewId); + if (initialView?.dataEvent) { + send({ + type: "ui_management", + sessionId: selectedId, + action: "list", + event: "ui:get-data", + params: { event: initialView.dataEvent } + }); + } + } + setGenericModuleOpen(match); + return; + } else if (trimmed.startsWith("/") && modules.length === 0) { + console.warn(`[App] No modules found for session ${selectedId}, but slash command used. Triggering refresh. Modules exist for IDs:`, Array.from(sessionUiModules.keys())); + // Fallback: if we see a slash command but have 0 modules, try to refresh them once + send({ type: "ui_management", sessionId: selectedId, action: "list", event: "ui:list-modules" }); + // DO NOT fall through to handleSend (LLM prompt) for slash commands when we're out of sync + return; + } + } + if (trimmed === "/flows") { setFlowPickerOpen(true); return; @@ -413,8 +455,9 @@ export default function App() { setFlowNewOpen(true); return; } + handleSend(text, images); - }, [handleSend]); + }, [handleSend, selectedId, sessionUiModules]); const openspecActions = useOpenSpecActions({ send, openspecMap, setPreviewState, clearAllContentViews }); const { @@ -538,8 +581,15 @@ export default function App() { onRenameTerminal={handleRenameTerminal} onCollapseSidebar={sidebar.toggleCollapse} commandsMap={sessionCommands} - flowsMap={sessionFlows} + flowsMap={(() => { + const m = new Map(); + for (const [sid, dataMap] of sessionUiData.entries()) { + m.set(sid, dataMap.get("flow:list-flows") || []); + } + return m; + })()} onKillProcess={handleKillProcess} + onOpenTerminals={(cwd) => navigate(`/folder/${encodeFolderPath(cwd)}/terminals`)} onOpenEditor={(cwd) => navigate(`/folder/${encodeFolderPath(cwd)}/editor`)} editorStatuses={editorStatuses} @@ -641,6 +691,7 @@ export default function App() { subscribedRef.current.add(selectedId); send({ type: "subscribe", sessionId: selectedId, lastSeq: 0 }); }} + uiModules={selectedId ? sessionUiModules.get(selectedId) : undefined} /> {/* Mobile info strip */} {isMobile && selectedSession && ( @@ -977,6 +1028,39 @@ export default function App() { onCancel={() => setFlowEditFlowName(null)} /> )} + {genericModuleOpen && ( + { + const sessionDataMap = sessionUiData.get(selectedId || ""); + if (!sessionDataMap) return {}; + return Object.fromEntries(sessionDataMap.entries()); + })()} + onAction={(action, params) => { + if (selectedId) { + send({ + type: "ui_management", + sessionId: selectedId, + action: action.label, + event: action.emit, + params + }); + } + }} + onRefresh={(view) => { + if (selectedId && view.dataEvent) { + send({ + type: "ui_management", + sessionId: selectedId, + action: "list", + event: "ui:get-data", + params: { event: view.dataEvent } + }); + } + }} + onCancel={() => setGenericModuleOpen(null)} + /> + )} {flowDeletePickerOpen && ( )} + ); } diff --git a/packages/client/src/components/GenericExtensionDialog.tsx b/packages/client/src/components/GenericExtensionDialog.tsx new file mode 100644 index 000000000..7ad09e3f5 --- /dev/null +++ b/packages/client/src/components/GenericExtensionDialog.tsx @@ -0,0 +1,348 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { Icon } from "@mdi/react"; +import * as mdi from "@mdi/js"; +import { DialogPortal } from "./DialogPortal.js"; +import type { ExtensionUiModule, UiView, UiAction, UiField } from "@blackbelt-technology/pi-dashboard-shared/types.js"; + +interface Props { + module: ExtensionUiModule; + data: Record; // Data for each view, keyed by view.id or dataEvent + onAction: (action: UiAction, params?: Record) => void; + onCancel: () => void; + onRefresh: (view: UiView) => void; +} + +export function GenericExtensionDialog({ + module, + data, + onAction, + onCancel, + onRefresh, +}: Props) { + const [activeViewId, setActiveViewId] = useState(module.initialViewId); + const activeView = useMemo(() => + module.views.find(v => v.id === activeViewId) || module.views[0], + [module, activeViewId]); + + const [formData, setFormData] = useState>({}); + + // Refresh data and reset form when view changes + useEffect(() => { + if (activeView) { + onRefresh(activeView); + if (activeView.type === "form") { + // Populate form with existing data if available, or defaults + const existing = data[activeView.id]?.[0] || data[activeView.dataEvent || ""]?.[0] || {}; + const initialData: Record = { ...existing }; + activeView.fields?.forEach(f => { + if (initialData[f.key] === undefined) { + if (f.type === "select" && f.options?.[0]) initialData[f.key] = f.options[0].value; + else if (f.type === "boolean") initialData[f.key] = false; + } + }); + setFormData(initialData); + } + } + }, [activeViewId, data]); + + const handleAction = (action: UiAction, item?: any) => { + if (action.emit === "ui:navigate") { + if (action.params?.viewId) setActiveViewId(action.params.viewId); + return; + } + + if (action.confirm && !window.confirm(action.confirm)) return; + + let params = { ...action.params }; + if (item && action.primaryParam) { + params[action.primaryParam] = item.id || item.name || item.key; + } + + // Merge form data if this is a submission from a form + if (activeView.type === "form") { + params = { ...params, ...formData }; + // For forms, we usually want to navigate back to list ONLY on success. + // Since the dashboard protocol is fire-and-forget, we'll assume success + // but maybe allow the extension to control this in the future. + // For now, let's auto-navigate back only if it's NOT a custom emit. + if (action.emit.includes("add-request") || action.emit.includes("create")) { + setActiveViewId(module.initialViewId); + } + } + + onAction(action, params); + }; + + const renderIcon = (iconName?: string, size = 0.7, className = "") => { + if (!iconName) return null; + const path = (mdi as any)[`mdi${iconName.charAt(0).toUpperCase()}${iconName.slice(1)}`]; + if (!path) return null; + return ; + }; + + const renderTable = (view: UiView) => { + const items = data[view.id] || data[view.dataEvent || ""] || []; + return ( +
+ + + + {view.fields?.map(f => ( + + ))} + {view.itemActions && } + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((item, idx) => ( + + {view.fields?.map(f => ( + + ))} + {view.itemActions && ( + + )} + + )) + )} + +
+ {f.label} + Actions
+ No items found +
+ {f.type === "code" ? ( + {item[f.key]} + ) : f.type === "boolean" ? ( + {item[f.key] ? "✓" : "✗"} + ) : ( + item[f.key] + )} + + {view.itemActions.map((action, aIdx) => ( + + ))} +
+
+ ); + }; + + const renderField = (f: UiField) => { + return ( +
+ + {f.description && ( +

+ {f.description} +

+ )} + {f.type === "textarea" ? ( +