From ae158c31f04ebcb9914c8259357789350be87d10 Mon Sep 17 00:00:00 2001 From: farazcsk Date: Fri, 10 Oct 2025 12:14:00 +0100 Subject: [PATCH 1/2] feat: upgrade to ai sdk 5 --- .changeset/playground-ui-improvements.md | 13 + .changeset/remove-astro-docs.md | 4 + PLAYGROUND_EDITING_VISION.md | 730 ++++++ client/dashboard/package.json | 19 +- .../src/components/ai-elements/code-block.tsx | 179 ++ .../components/ai-elements/conversation.tsx | 97 + .../src/components/ai-elements/message.tsx | 76 + .../components/ai-elements/prompt-input.tsx | 1366 +++++++++++ .../src/components/ai-elements/response.tsx | 22 + .../src/components/ai-elements/tool.tsx | 157 ++ .../src/components/chat/ChatInput.tsx | 57 + .../src/components/chat/ChatMessages.tsx | 71 + .../src/components/tool-list/ToolList.tsx | 7 + .../src/components/ui/collapsible.tsx | 31 + .../src/components/ui/input-group.tsx | 173 ++ client/dashboard/src/components/ui/input.tsx | 19 +- .../dashboard/src/components/ui/textarea.tsx | 14 + .../dashboard/src/lib/CustomChatTransport.ts | 56 + .../src/pages/playground/Agentify.tsx | 14 +- .../src/pages/playground/ChatContext.tsx | 4 +- .../src/pages/playground/ChatHistory.ts | 84 +- .../src/pages/playground/ChatWindow.tsx | 715 +++--- .../src/pages/playground/EditToolDialog.tsx | 174 ++ .../pages/playground/ManageToolsDialog.tsx | 279 +++ .../src/pages/playground/Playground.tsx | 360 ++- .../src/pages/playground/PlaygroundAuth.tsx | 122 + .../playground/PlaygroundConfigPanel.tsx | 576 +++++ .../pages/playground/PlaygroundLogsPanel.tsx | 339 +++ .../src/pages/playground/PlaygroundRHS.tsx | 68 +- .../playground/useMessageHistoryNavigation.ts | 6 +- .../src/pages/toolBuilder/ToolBuilder.tsx | 10 +- .../src/pages/toolBuilder/Toolify.tsx | 3 +- pnpm-lock.yaml | 2011 +++++++++++++++-- pnpm-workspace.yaml | 14 +- 34 files changed, 7143 insertions(+), 727 deletions(-) create mode 100644 .changeset/playground-ui-improvements.md create mode 100644 .changeset/remove-astro-docs.md create mode 100644 PLAYGROUND_EDITING_VISION.md create mode 100644 client/dashboard/src/components/ai-elements/code-block.tsx create mode 100644 client/dashboard/src/components/ai-elements/conversation.tsx create mode 100644 client/dashboard/src/components/ai-elements/message.tsx create mode 100644 client/dashboard/src/components/ai-elements/prompt-input.tsx create mode 100644 client/dashboard/src/components/ai-elements/response.tsx create mode 100644 client/dashboard/src/components/ai-elements/tool.tsx create mode 100644 client/dashboard/src/components/chat/ChatInput.tsx create mode 100644 client/dashboard/src/components/chat/ChatMessages.tsx create mode 100644 client/dashboard/src/components/ui/collapsible.tsx create mode 100644 client/dashboard/src/components/ui/input-group.tsx create mode 100644 client/dashboard/src/lib/CustomChatTransport.ts create mode 100644 client/dashboard/src/pages/playground/EditToolDialog.tsx create mode 100644 client/dashboard/src/pages/playground/ManageToolsDialog.tsx create mode 100644 client/dashboard/src/pages/playground/PlaygroundAuth.tsx create mode 100644 client/dashboard/src/pages/playground/PlaygroundConfigPanel.tsx create mode 100644 client/dashboard/src/pages/playground/PlaygroundLogsPanel.tsx diff --git a/.changeset/playground-ui-improvements.md b/.changeset/playground-ui-improvements.md new file mode 100644 index 000000000..091f6eab7 --- /dev/null +++ b/.changeset/playground-ui-improvements.md @@ -0,0 +1,13 @@ +--- +"dashboard": patch +--- + +Fix playground keyboard shortcuts and improve UI components + +- Fixed keyboard shortcuts in playground chat input - Enter now properly submits messages (Shift+Enter for newlines) +- Fixed TextArea component to properly accept and forward event handlers (onKeyDown, onCompositionStart, onCompositionEnd, onPaste) +- Fixed AI SDK 5 compatibility by changing maxTokens to maxOutputTokens in CustomChatTransport +- Fixed Button variant types in EditToolDialog (destructive-secondary, secondary) +- Fixed Input component onChange handler to use value parameter directly +- Fixed type mismatches between ToolsetEntry and Toolset in Playground component +- Added missing Tool type import diff --git a/.changeset/remove-astro-docs.md b/.changeset/remove-astro-docs.md new file mode 100644 index 000000000..76e73bc2a --- /dev/null +++ b/.changeset/remove-astro-docs.md @@ -0,0 +1,4 @@ +--- +--- + +Remove accidentally committed Astro docs build artifacts diff --git a/PLAYGROUND_EDITING_VISION.md b/PLAYGROUND_EDITING_VISION.md new file mode 100644 index 000000000..e7d9cc53c --- /dev/null +++ b/PLAYGROUND_EDITING_VISION.md @@ -0,0 +1,730 @@ +# Playground Editing Experience - Deep Analysis & Vision + +## Current State Analysis + +### What We Have + +#### 1. **Existing Tool Management Functionality** +- **ToolList Component** (`src/components/tool-list/ToolList.tsx`): + - Selection mode for adding tools to toolsets + - Remove mode for removing tools from toolsets + - Inline editing of tool names and descriptions via EditableText + - Grouping by source (package, function, custom) + - Round-robin method sorting for visual variety + - Command palette integration for keyboard navigation + +- **AddToolsDialog** (`src/pages/toolsets/AddToolsDialog.tsx`): + - Search and filter tools by source + - Select multiple tools to add to a toolset + - Shows which tools are already in the toolset + +- **ToolCard** (`src/pages/toolsets/ToolCard.tsx`): + - Editable tool name and description + - Auto-summarize toggle + - Update tool variations via API + +#### 2. **Current Playground Structure** +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Toolset Selector] │ +├─────────────────────────────────────────────────────────────┤ +│ ▼ Toolset Info │ +│ Name, Slug, Description, Tool Count │ +├─────────────────────────────────────────────────────────────┤ +│ [Environment Selector] │ +├─────────────────────────────────────────────────────────────┤ +│ ▼ Tools (N) │ +│ [Package Name] │ +│ ☑ Tool 1 - description [GET] │ +│ ☑ Tool 2 - description [POST] │ +│ [Functions] │ +│ ☑ Function Tool - description │ +├─────────────────────────────────────────────────────────────┤ +│ ▼ Model Settings │ +│ Temperature slider │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 3. **Available API Operations** +- `toolsets.update({ slug, toolUrns[], name, description, ... })` - Update entire toolset including tool list +- `variations.upsertGlobal()` - Update individual tool variations (name, description, etc.) +- `templates.update()` - Update prompt templates + +### The Gap: What's Missing for OpenAI-style Editing + +Based on OpenAI's Assistants Playground pattern (inferred from your description), the key missing pieces are: + +## Vision: Playground as a Temporary Editing Sandbox + +### Core Philosophy +**The playground should be a lightweight, ephemeral workspace where you can:** +1. Test toolset configurations without modifying the source toolset +2. Add/remove tools temporarily to see how they work together +3. Edit tool metadata on the fly +4. See live logs of tool execution +5. **Eventually** choose to persist changes back to the toolset + +### Proposed Architecture + +#### **Phase 1: Local State Management (Immediate)** +Add a "working copy" layer in the playground that shadows the real toolset: + +```typescript +interface PlaygroundState { + // Original toolset data from server + originalToolset: Toolset; + + // Working copy with local modifications + workingToolset: { + name: string; + description: string; + toolUrns: string[]; // Can add/remove without API calls + }; + + // Tool metadata overrides (local only, not persisted) + toolOverrides: Map; + + // Track what's changed + hasChanges: boolean; + changes: { + addedTools: string[]; + removedTools: string[]; + modifiedTools: string[]; + metadata: { name?: boolean; description?: boolean }; + }; +} +``` + +**Key Benefits:** +- Fast, instant updates (no API calls) +- Can experiment freely +- Easy to discard changes +- Can implement undo/redo later + +#### **Phase 2: Change Persistence (Future)** +Add UI to save changes: + +```tsx +{hasChanges && ( +
+ + +
+)} +``` + +--- + +## Detailed Implementation Plan + +### 1. **Tool Management in Left Panel** + +#### A. **Add "+" Button to Tools Section Header** +```tsx + +
+ Tools ({workingToolset.toolUrns.length}) + +
+ + {/* NEW: Action buttons on the right */} +
+ +
+
+``` + +#### B. **Make Each Tool Row Editable & Removable** +```tsx +
+
+ { + updateToolOverride(tool.id, { enabled: !!checked }); + }} + /> + + {/* Editable name */} + { + updateToolOverride(tool.id, { name: newName }); + }} + > + + {toolOverrides.get(tool.id)?.name ?? tool.name} + + +
+ + {/* Show actions on hover */} +
+ {tool.httpMethod && } + + +
+
+``` + +#### C. **Add Tools Dialog (Reuse Existing)** +Invoke the existing `AddToolsDialog` but in a playground-specific mode: +- Pass current `workingToolset.toolUrns` as `selectedUrns` +- On submit, update local state (not API) +- Show "Added to playground" instead of "Added to toolset" + +### 2. **Toolset Info Editing** + +Make the Toolset Info section fully editable: + +```tsx + +
+ + updateWorkingToolset({ name })} + > + + {workingToolset.name} + + +
+ +
+ + updateWorkingToolset({ description })} + lines={3} + > + + {workingToolset.description || "Click to add description"} + + +
+ + {/* Show change indicator */} + {hasMetadataChanges && ( + + Unsaved changes + + )} +
+``` + +### 3. **Visual Change Indicators** + +Add subtle indicators for what's changed: + +```tsx +// Modified tool name + + {toolOverrides.get(tool.id)?.name ?? tool.name} + {toolOverrides.has(tool.id) && ( + + )} + + +// Newly added tool (not in original toolset) +{!originalToolset.toolUrns.includes(tool.id) && ( + New +)} + +// Tool will be removed (unchecked) +{!toolEnabled && ( + Hidden +)} +``` + +### 4. **Change Summary Panel (Optional but Powerful)** + +Add a collapsible "Changes" section at the bottom: + +```tsx +{hasChanges && ( +
+ + +
+ + Changes ({totalChanges}) + + +
+
+ + + {changes.addedTools.length > 0 && ( +
+ + +{changes.addedTools.length} tools added + +
    + {changes.addedTools.map(urn => ( +
  • • {getToolName(urn)}
  • + ))} +
+
+ )} + + {changes.removedTools.length > 0 && ( +
+ + -{changes.removedTools.length} tools removed + +
+ )} + + {changes.metadata.name && ( +
+ • Toolset name changed +
+ )} +
+
+
+)} +``` + +--- + +## Implementation Phases + +### **Phase 1: Foundation (Week 1)** +- [ ] Create `usePlaygroundState` hook to manage working copy +- [ ] Add local tool overrides (enable/disable, rename) +- [ ] Make tool names editable inline +- [ ] Add remove button to each tool (local only) +- [ ] Show visual indicators for changed tools + +### **Phase 2: Tool Management (Week 1-2)** +- [ ] Add "+" button to Tools section header +- [ ] Wire up AddToolsDialog in playground mode +- [ ] Implement tool filtering (show only enabled tools in chat) +- [ ] Add tool description editing +- [ ] Group management (expand/collapse all, sort options) + +### **Phase 3: Toolset Metadata (Week 2)** +- [ ] Make toolset name editable +- [ ] Make toolset description editable +- [ ] Add change indicators to metadata fields +- [ ] Implement basic validation (name regex, required fields) + +### **Phase 4: Change Tracking UI (Week 2-3)** +- [ ] Build change summary panel +- [ ] Add "Discard Changes" button +- [ ] Add "Save to Toolset" button (calls `toolsets.update()`) +- [ ] Handle optimistic updates and error states +- [ ] Add confirmation dialogs for destructive actions + +### **Phase 5: Polish & UX (Week 3)** +- [ ] Add keyboard shortcuts (⌘+S to save, ESC to discard) +- [ ] Implement undo/redo stack +- [ ] Add loading states during save +- [ ] Toast notifications for success/errors +- [ ] Autosave draft state to localStorage + +### **Phase 6: Advanced Features (Future)** +- [ ] Staging area (save drafts without publishing) +- [ ] Version history / snapshots +- [ ] Share playground configuration via URL +- [ ] Compare current vs original (diff view) +- [ ] Bulk operations (remove all tools from source X) +- [ ] Tool templates (common configurations) + +--- + +## Key Design Decisions + +### 1. **Why Local State First?** +- **Speed**: No API latency for every change +- **Experimentation**: Users can try things without fear +- **Undo/Redo**: Easy to implement with immutable state +- **Offline**: Works even if server is slow/down + +### 2. **Checkbox vs Remove Button** +Both patterns exist in the wild: +- **OpenAI**: Uses checkboxes (our current approach) +- **Anthropic Console**: Uses remove buttons + +**Recommendation**: Keep checkboxes but change semantics: +- Unchecked = "Hidden from chat" (still in toolset) +- Remove button = "Remove from playground session" + +This gives users fine-grained control: +- Temporarily disable tools without removing +- Truly remove tools they never want + +### 3. **When to Persist Changes?** +**Option A: Manual Save** (Recommended) +- Explicit "Save" button +- Clear control over when changes apply +- Can discard mistakes easily + +**Option B: Autosave** +- Saves every N seconds if changes exist +- Less cognitive load +- Risk of unwanted changes + +**Option C: Save on Navigate Away** +- Prompt "You have unsaved changes" dialog +- Like Google Docs +- Can be annoying + +**Recommendation**: Start with Manual Save (Option A), add autosave to localStorage as draft recovery. + +### 4. **Tool Editing Scope** +**What should be editable in playground?** + +✅ **Should be editable (local only):** +- Tool name (display name in chat) +- Tool description (shown to LLM) +- Enable/disable (show in chat) + +❌ **Should NOT be editable here:** +- Tool implementation (OpenAPI spec, function code) +- Authentication/headers (security risk) +- HTTP method/path (would break tool) + +**Why?** Playground is for **configuration**, not **implementation**. Editing actual tool behavior should be done in the toolset detail page or deployment flow. + +### 5. **Environment Switching** +**What happens to working copy when environment changes?** + +**Option A: Reset to New Environment** +- Discard all changes +- Load fresh instance for new environment +- Simple, but loses work + +**Option B: Ask User** +- "You have unsaved changes. Discard or save first?" +- More control, more clicks + +**Option C: Keep Local Changes Across Environments** +- Working copy independent of environment +- Complex to reason about + +**Recommendation**: Option B (ask user). Changes are tied to a specific toolset+environment combination. + +--- + +## Technical Implementation Details + +### State Management Structure + +```typescript +// New hook: usePlaygroundState +export function usePlaygroundState( + toolsetSlug: string, + environmentSlug: string +) { + const { data: instance } = useInstance({ toolsetSlug, environmentSlug }); + const { data: toolset } = useToolset(toolsetSlug); + + // Local state for working copy + const [workingState, setWorkingState] = useState({ + metadata: { + name: toolset?.name ?? "", + description: toolset?.description ?? "", + }, + toolUrns: instance?.tools?.map(t => t.toolUrn) ?? [], + toolOverrides: new Map(), + }); + + // Compute changes + const changes = useMemo(() => { + const original = new Set(instance?.tools?.map(t => t.toolUrn) ?? []); + const current = new Set(workingState.toolUrns); + + return { + addedTools: [...current].filter(urn => !original.has(urn)), + removedTools: [...original].filter(urn => !current.has(urn)), + modifiedTools: [...workingState.toolOverrides.keys()], + metadata: { + name: workingState.metadata.name !== toolset?.name, + description: workingState.metadata.description !== toolset?.description, + }, + }; + }, [workingState, instance, toolset]); + + const hasChanges = + changes.addedTools.length > 0 || + changes.removedTools.length > 0 || + changes.modifiedTools.length > 0 || + changes.metadata.name || + changes.metadata.description; + + // Actions + const addTool = (toolUrn: string) => { + setWorkingState(prev => ({ + ...prev, + toolUrns: [...prev.toolUrns, toolUrn], + })); + }; + + const removeTool = (toolUrn: string) => { + setWorkingState(prev => ({ + ...prev, + toolUrns: prev.toolUrns.filter(urn => urn !== toolUrn), + })); + }; + + const updateToolOverride = (toolUrn: string, override: ToolOverride) => { + setWorkingState(prev => { + const overrides = new Map(prev.toolOverrides); + overrides.set(toolUrn, { ...overrides.get(toolUrn), ...override }); + return { ...prev, toolOverrides: overrides }; + }); + }; + + const saveChanges = async () => { + await client.toolsets.update({ + slug: toolsetSlug, + updateToolsetRequestBody: { + name: workingState.metadata.name, + description: workingState.metadata.description, + toolUrns: workingState.toolUrns, + }, + }); + + // TODO: Also save tool variations if overrides exist + for (const [toolUrn, override] of workingState.toolOverrides) { + if (override.name || override.description) { + await client.variations.upsertGlobal({ + srcToolUrn: toolUrn, + name: override.name, + description: override.description, + }); + } + } + + // Clear local state + reset(); + }; + + const discardChanges = () => { + setWorkingState({ + metadata: { + name: toolset?.name ?? "", + description: toolset?.description ?? "", + }, + toolUrns: instance?.tools?.map(t => t.toolUrn) ?? [], + toolOverrides: new Map(), + }); + }; + + return { + workingState, + hasChanges, + changes, + actions: { + addTool, + removeTool, + updateToolOverride, + updateMetadata: (metadata: Partial) => { + setWorkingState(prev => ({ + ...prev, + metadata: { ...prev.metadata, ...metadata }, + })); + }, + saveChanges, + discardChanges, + }, + }; +} +``` + +### Tools Filtering for Chat + +When rendering the chat, filter tools based on working state: + +```typescript +// In ChatWindow or wherever tools are passed to AI +const chatTools = useMemo(() => { + return workingState.toolUrns + .map(urn => allTools.find(t => t.toolUrn === urn)) + .filter(Boolean) + .filter(tool => { + // Respect enable/disable override + const override = workingState.toolOverrides.get(tool.toolUrn); + return override?.enabled !== false; + }) + .map(tool => { + // Apply name/description overrides + const override = workingState.toolOverrides.get(tool.toolUrn); + return { + ...tool, + name: override?.name ?? tool.name, + description: override?.description ?? tool.description, + }; + }); +}, [workingState, allTools]); +``` + +--- + +## Comparison with OpenAI Assistants Playground + +Based on typical OpenAI Assistants patterns: + +| Feature | OpenAI | Our Vision | +|---------|--------|------------| +| **Tool Selection** | Checkbox list | ✅ Checkbox list (done) | +| **Add Tools** | "+" button in header | ✅ Planned Phase 2 | +| **Remove Tools** | "X" button per tool | ✅ Planned Phase 2 | +| **Edit Tool Name** | Inline edit | ✅ Planned Phase 2 | +| **Edit Instructions** | Textarea | ✅ Could add as system prompt | +| **Model Selection** | Dropdown | ⚠️ Not in scope (toolset-level) | +| **Temperature** | Slider | ✅ Already have | +| **Save Changes** | Auto-save | ✅ Manual save (Phase 4) | +| **Test in Chat** | Right panel | ✅ Already have | +| **View Logs** | Right panel | ✅ Already have | + +**Key Differences:** +1. **OpenAI**: Assistants are mutable, first-class entities + **Gram**: Toolsets are blueprints; instances are ephemeral + +2. **OpenAI**: Changes save immediately to assistant + **Gram**: Changes are local until explicitly saved + +3. **OpenAI**: Code Interpreter, File Search are special tools + **Gram**: All tools are uniform (HTTP, function, prompt) + +--- + +## Risk & Mitigation + +### Risk 1: Confusion Between Playground and Toolset Pages +**Problem**: Users might not understand that playground changes are temporary. + +**Mitigation**: +- Clear labeling: "Playground Configuration" vs "Toolset Configuration" +- Visual indicator when changes exist: "Unsaved Changes" badge +- Confirmation dialog before navigating away with unsaved changes +- Help tooltip explaining the difference + +### Risk 2: Accidental Overwrites +**Problem**: Saving playground changes might overwrite production toolset. + +**Mitigation**: +- Confirmation dialog on save: "Save changes to [Toolset Name]?" +- Show diff before saving (added/removed tools) +- Option to "Save as New Toolset" instead +- Environment selector warns if on production env + +### Risk 3: Performance with Many Tools +**Problem**: Large toolsets (100+ tools) might be slow to render/edit. + +**Mitigation**: +- Virtual scrolling for tool list (react-window) +- Debounce search/filter inputs +- Pagination or "Load More" for huge lists +- Lazy load tool details on expand + +### Risk 4: State Synchronization +**Problem**: If toolset is edited elsewhere while playground is open, changes conflict. + +**Mitigation**: +- Poll for updates every N seconds (like Google Docs) +- Show banner: "Toolset was updated. Reload?" +- Optimistic UI with rollback on conflict +- Version checking before save (compare timestamps) + +--- + +## Success Metrics + +### User Behavior +- % of playground sessions that modify tools +- % of playground sessions that save changes +- Average time to add/remove tools +- Tool churn rate (add then remove) + +### Performance +- Time to load playground (<500ms) +- Time to add tool to working copy (<50ms) +- Time to save changes (<2s) +- Tool list render time (<100ms) + +### Quality +- Bug reports related to tool management +- Support tickets about "lost changes" +- User confusion (via surveys/feedback) + +--- + +## Open Questions + +1. **Should playground changes affect other users' playground sessions?** + - If yes, need real-time sync (WebSockets) + - If no, simpler but isolated + +2. **Should we version playground configurations?** + - Could save snapshots: "Playground Session 2024-01-15 3:42pm" + - Users could restore or share configurations + +3. **Should tools have per-environment overrides?** + - Tool might work differently in dev vs prod + - Could store overrides per environment + +4. **What about resource variables (API keys, etc)?** + - Not covered in this doc + - Needs separate design (security-sensitive) + +5. **Should we support "branching" from a toolset?** + - Create derivative toolset from playground session + - Like "Save As" but with provenance tracking + +--- + +## Conclusion + +The vision is to make the Playground a **true editing environment** where users can: +1. ✨ **Experiment freely** without fear of breaking production +2. 🛠️ **Configure toolsets** directly in the context where they're tested +3. 📊 **See immediate feedback** via chat and logs +4. 💾 **Save when ready** with clear control over persistence + +This aligns with OpenAI's pattern while respecting Gram's architecture (toolsets as blueprints, instances as runtime environments). + +**The key insight**: Playground should feel like a **draft mode** for toolsets, not a separate experience. + +### Next Steps +1. Review this doc with team +2. Validate assumptions about user workflow +3. Prioritize phases based on user feedback +4. Start with Phase 1 (foundation) for quick wins +5. Iterate based on real usage patterns + +--- + +**Last Updated**: 2025-01-06 +**Author**: Claude (Anthropic) +**Status**: Draft for Review diff --git a/client/dashboard/package.json b/client/dashboard/package.json index a0403ff5f..6f3edb96a 100644 --- a/client/dashboard/package.json +++ b/client/dashboard/package.json @@ -14,24 +14,28 @@ }, "dependencies": { "@ai-sdk/openai": "catalog:", - "@ai-sdk/react": "^1.2.12", + "@ai-sdk/react": "catalog:", + "@ai-sdk/ui-utils": "catalog:", "@datadog/browser-rum": "^6.20.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@gram/client": "workspace:*", - "@openrouter/ai-sdk-provider": "0.7.5", + "@openrouter/ai-sdk-provider": "^1.2.0", "@polar-sh/checkout": "^0.1.11", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -39,6 +43,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-use-controllable-state": "^1.2.2", "@react-three/drei": "^10.0.7", "@react-three/fiber": "^9.1.2", "@react-three/postprocessing": "^3.0.4", @@ -47,12 +52,14 @@ "@tailwindcss/vite": "^4.1.13", "@tanstack/react-query": "catalog:", "@tanstack/react-table": "^8.21.3", + "@xyflow/react": "^12.8.6", "ai": "catalog:", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "hast": "^1.0.0", "lucide-react": "^0.544.0", "motion": "^12.23.14", "motion-plus": "^1.5.1", @@ -65,13 +72,17 @@ "react-error-boundary": "^6.0.0", "react-merge-refs": "^3.0.2", "react-router": "^7.9.1", - "shiki": "^3.13.0", + "react-syntax-highlighter": "^15.6.6", + "shiki": "^3.14.0", "sonner": "^2.0.7", + "streamdown": "^1.3.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", "three": "^0.176.0", + "tokenlens": "^1.3.1", "tunnel-rat": "^0.1.2", "tw-animate-css": "^1.3.8", + "use-stick-to-bottom": "^1.1.1", "uuid": "^13.0.0", "vaul": "^1.1.2", "zod": "^3.20.0", @@ -79,9 +90,11 @@ }, "devDependencies": { "@eslint/js": "^9.35.0", + "@types/hast": "^3.0.4", "@types/node": "^24.5.2", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@types/react-syntax-highlighter": "^15.5.13", "@types/three": "^0.180.0", "@vitejs/plugin-react": "^5.0.3", "eslint": "^9.35.0", diff --git a/client/dashboard/src/components/ai-elements/code-block.tsx b/client/dashboard/src/components/ai-elements/code-block.tsx new file mode 100644 index 000000000..b969f4a0e --- /dev/null +++ b/client/dashboard/src/components/ai-elements/code-block.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { Element } from "hast"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { + type ComponentProps, + createContext, + type HTMLAttributes, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki"; + +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +type CodeBlockContextType = { + code: string; +}; + +const CodeBlockContext = createContext({ + code: "", +}); + +const lineNumberTransformer: ShikiTransformer = { + name: "line-numbers", + line(node: Element, line: number) { + node.children.unshift({ + type: "element", + tagName: "span", + properties: { + className: [ + "inline-block", + "min-w-10", + "mr-4", + "text-right", + "select-none", + "text-muted-foreground", + ], + }, + children: [{ type: "text", value: String(line) }], + }); + }, +}; + +export async function highlightCode( + code: string, + language: BundledLanguage, + showLineNumbers = false, +) { + const transformers: ShikiTransformer[] = showLineNumbers + ? [lineNumberTransformer] + : []; + + return await Promise.all([ + codeToHtml(code, { + lang: language, + theme: "one-light", + transformers, + }), + codeToHtml(code, { + lang: language, + theme: "one-dark-pro", + transformers, + }), + ]); +} + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => { + const [html, setHtml] = useState(""); + const [darkHtml, setDarkHtml] = useState(""); + const mounted = useRef(false); + + useEffect(() => { + highlightCode(code, language, showLineNumbers).then(([light, dark]) => { + if (!mounted.current) { + setHtml(light); + setDarkHtml(dark); + mounted.current = true; + } + }); + + return () => { + mounted.current = false; + }; + }, [code, language, showLineNumbers]); + + return ( + +
+
+
+
+ {children && ( +
+ {children} +
+ )} +
+
+ + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + onCopy?: () => void; + onError?: (error: Error) => void; + timeout?: number; +}; + +export const CodeBlockCopyButton = ({ + onCopy, + onError, + timeout = 2000, + children, + className, + ...props +}: CodeBlockCopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const { code } = useContext(CodeBlockContext); + + const copyToClipboard = async () => { + if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { + onError?.(new Error("Clipboard API not available")); + return; + } + + try { + await navigator.clipboard.writeText(code); + setIsCopied(true); + onCopy?.(); + setTimeout(() => setIsCopied(false), timeout); + } catch (error) { + onError?.(error as Error); + } + }; + + const Icon = isCopied ? CheckIcon : CopyIcon; + + return ( + + ); +}; diff --git a/client/dashboard/src/components/ai-elements/conversation.tsx b/client/dashboard/src/components/ai-elements/conversation.tsx new file mode 100644 index 000000000..0ba5d941b --- /dev/null +++ b/client/dashboard/src/components/ai-elements/conversation.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ArrowDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = "No messages yet", + description = "Start a conversation to see messages here", + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/client/dashboard/src/components/ai-elements/message.tsx b/client/dashboard/src/components/ai-elements/message.tsx new file mode 100644 index 000000000..e9b9dd523 --- /dev/null +++ b/client/dashboard/src/components/ai-elements/message.tsx @@ -0,0 +1,76 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; +import type { UIMessage } from "ai"; +import { cva, type VariantProps } from "class-variance-authority"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +const messageContentVariants = cva( + "is-user:dark flex flex-col gap-2 overflow-hidden rounded-lg text-sm", + { + variants: { + variant: { + contained: [ + "max-w-[80%] px-4 py-3", + "group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground", + "group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground", + ], + flat: [ + "group-[.is-user]:max-w-[80%] group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground", + "group-[.is-assistant]:text-foreground", + ], + }, + }, + defaultVariants: { + variant: "contained", + }, + }, +); + +export type MessageContentProps = HTMLAttributes & + VariantProps; + +export const MessageContent = ({ + children, + className, + variant, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageAvatarProps = ComponentProps & { + src: string; + name?: string; +}; + +export const MessageAvatar = ({ + src, + name, + className, + ...props +}: MessageAvatarProps) => ( + + + {name?.slice(0, 2) || "ME"} + +); diff --git a/client/dashboard/src/components/ai-elements/prompt-input.tsx b/client/dashboard/src/components/ai-elements/prompt-input.tsx new file mode 100644 index 000000000..1921c45aa --- /dev/null +++ b/client/dashboard/src/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1366 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import type { ChatStatus, FileUIPart } from "ai"; +import { + ArrowUpIcon, + ImageIcon, + Loader2Icon, + MicIcon, + PaperclipIcon, + PlusIcon, + SquareIcon, + XIcon, +} from "lucide-react"; +import { nanoid } from "nanoid"; +import { + type ChangeEvent, + type ChangeEventHandler, + Children, + type ClipboardEventHandler, + type ComponentProps, + createContext, + type FormEvent, + type FormEventHandler, + Fragment, + type HTMLAttributes, + type KeyboardEventHandler, + type PropsWithChildren, + type ReactNode, + type RefObject, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +// ============================================================================ +// Provider Context & Types +// ============================================================================ + +export type AttachmentsContext = { + files: (FileUIPart & { id: string })[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +}; + +export type TextInputContext = { + value: string; + setInput: (v: string) => void; + clear: () => void; +}; + +export type PromptInputControllerProps = { + textInput: TextInputContext; + attachments: AttachmentsContext; + /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ + __registerFileInput: ( + ref: RefObject, + open: () => void, + ) => void; +}; + +const PromptInputController = createContext( + null, +); +const ProviderAttachmentsContext = createContext( + null, +); + +export const usePromptInputController = () => { + const ctx = useContext(PromptInputController); + if (!ctx) { + throw new Error( + "Wrap your component inside to use usePromptInputController().", + ); + } + return ctx; +}; + +// Optional variants (do NOT throw). Useful for dual-mode components. +const useOptionalPromptInputController = () => + useContext(PromptInputController); + +export const useProviderAttachments = () => { + const ctx = useContext(ProviderAttachmentsContext); + if (!ctx) { + throw new Error( + "Wrap your component inside to use useProviderAttachments().", + ); + } + return ctx; +}; + +const useOptionalProviderAttachments = () => + useContext(ProviderAttachmentsContext); + +export type PromptInputProviderProps = PropsWithChildren<{ + initialInput?: string; +}>; + +/** + * Optional global provider that lifts PromptInput state outside of PromptInput. + * If you don't use it, PromptInput stays fully self-managed. + */ +export function PromptInputProvider({ + initialInput: initialTextInput = "", + children, +}: PromptInputProviderProps) { + // ----- textInput state + const [textInput, setTextInput] = useState(initialTextInput); + const clearInput = useCallback(() => setTextInput(""), []); + + // ----- attachments state (global when wrapped) + const [attachements, setAttachements] = useState< + (FileUIPart & { id: string })[] + >([]); + const fileInputRef = useRef(null); + const openRef = useRef<() => void>(() => {}); + + const add = useCallback((files: File[] | FileList) => { + const incoming = Array.from(files); + if (incoming.length === 0) return; + + setAttachements((prev) => + prev.concat( + incoming.map((file) => ({ + id: nanoid(), + type: "file" as const, + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + })), + ), + ); + }, []); + + const remove = useCallback((id: string) => { + setAttachements((prev) => { + const found = prev.find((f) => f.id === id); + if (found?.url) URL.revokeObjectURL(found.url); + return prev.filter((f) => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachements((prev) => { + for (const f of prev) if (f.url) URL.revokeObjectURL(f.url); + return []; + }); + }, []); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + files: attachements, + add, + remove, + clear, + openFileDialog, + fileInputRef, + }), + [attachements, add, remove, clear, openFileDialog], + ); + + const __registerFileInput = useCallback( + (ref: RefObject, open: () => void) => { + fileInputRef.current = ref.current; + openRef.current = open; + }, + [], + ); + + const controller = useMemo( + () => ({ + textInput: { + value: textInput, + setInput: setTextInput, + clear: clearInput, + }, + attachments, + __registerFileInput, + }), + [textInput, clearInput, attachments, __registerFileInput], + ); + + return ( + + + {children} + + + ); +} + +// ============================================================================ +// Component Context & Hooks +// ============================================================================ + +const LocalAttachmentsContext = createContext(null); + +export const usePromptInputAttachments = () => { + // Dual-mode: prefer provider if present, otherwise use local + const provider = useOptionalProviderAttachments(); + const local = useContext(LocalAttachmentsContext); + const context = provider ?? local; + if (!context) { + throw new Error( + "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider", + ); + } + return context; +}; + +export type PromptInputAttachmentProps = HTMLAttributes & { + data: FileUIPart & { id: string }; + className?: string; +}; + +export function PromptInputAttachment({ + data, + className, + ...props +}: PromptInputAttachmentProps) { + const attachments = usePromptInputAttachments(); + + const filename = data.filename || ""; + + const mediaType = + data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( + + +
+
+
+ {isImage ? ( + {filename + ) : ( +
+ +
+ )} +
+ +
+ + {attachmentLabel} +
+
+ +
+ {isImage && ( +
+ {filename +
+ )} +
+
+

+ {filename || (isImage ? "Image" : "Attachment")} +

+ {data.mediaType && ( +

+ {data.mediaType} +

+ )} +
+
+
+
+
+ ); +} + +export type PromptInputAttachmentsProps = Omit< + HTMLAttributes, + "children" +> & { + children: (attachment: FileUIPart & { id: string }) => ReactNode; +}; + +export function PromptInputAttachments({ + children, +}: PromptInputAttachmentsProps) { + const attachments = usePromptInputAttachments(); + + if (!attachments.files.length) { + return null; + } + + return attachments.files.map((file) => ( + {children(file)} + )); +} + +export type PromptInputActionAddAttachmentsProps = ComponentProps< + typeof DropdownMenuItem +> & { + label?: string; +}; + +export const PromptInputActionAddAttachments = ({ + label = "Add photos or files", + ...props +}: PromptInputActionAddAttachmentsProps) => { + const attachments = usePromptInputAttachments(); + + return ( + { + e.preventDefault(); + attachments.openFileDialog(); + }} + > + {label} + + ); +}; + +export type PromptInputMessage = { + text?: string; + files?: FileUIPart[]; +}; + +export type PromptInputProps = Omit< + HTMLAttributes, + "onSubmit" | "onError" +> & { + accept?: string; // e.g., "image/*" or leave undefined for any + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + maxFileSize?: number; // bytes + onError?: (err: { + code: "max_files" | "max_file_size" | "accept"; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent, + ) => void | Promise; +}; + +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props +}: PromptInputProps) => { + // Try to use a provider controller if present + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; + + // Refs + const inputRef = useRef(null); + const anchorRef = useRef(null); + const formRef = useRef(null); + + // Find nearest form to scope drag & drop + useEffect(() => { + const root = anchorRef.current?.closest("form"); + if (root instanceof HTMLFormElement) { + formRef.current = root; + } + }, []); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + if (accept.includes("image/*")) { + return f.type.startsWith("image/"); + } + // NOTE: keep simple; expand as needed + return true; + }, + [accept], + ); + + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = Array.from(fileList); + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + setItems((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: "file", + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError], + ); + + const add = usingProvider + ? (files: File[] | FileList) => controller.attachments.add(files) + : addLocal; + + const remove = usingProvider + ? (id: string) => controller.attachments.remove(id) + : (id: string) => + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }); + + const clear = usingProvider + ? () => controller.attachments.clear() + : () => + setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }); + + const openFileDialog = usingProvider + ? () => controller.attachments.openFileDialog() + : openFileDialogLocal; + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) return; + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add]); + + useEffect(() => { + if (!globalDrop) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect( + () => () => { + if (!usingProvider) { + for (const f of files) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + [usingProvider, files], + ); + + const handleChange: ChangeEventHandler = (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + }; + + const convertBlobUrlToDataUrl = async (url: string): Promise => { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + }; + + const ctx = useMemo( + () => ({ + files: files.map((item) => ({ ...item, id: item.id })), + add, + remove, + clear, + openFileDialog, + fileInputRef: inputRef, + }), + [files, add, remove, clear, openFileDialog], + ); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + const form = event.currentTarget; + const text = usingProvider + ? controller.textInput.value + : (() => { + const formData = new FormData(form); + return (formData.get("message") as string) || ""; + })(); + + // Reset form immediately after capturing text to avoid race condition + // where user input during async blob conversion would be lost + if (!usingProvider) { + form.reset(); + } + + // Convert blob URLs to data URLs asynchronously + Promise.all( + files.map(async ({ id, ...item }) => { + if (item.url && item.url.startsWith("blob:")) { + return { + ...item, + url: await convertBlobUrlToDataUrl(item.url), + }; + } + return item; + }), + ).then((convertedFiles: FileUIPart[]) => { + try { + const result = onSubmit({ text, files: convertedFiles }, event); + + // Handle both sync and async onSubmit + if (result instanceof Promise) { + result + .then(() => { + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + } else { + // Sync function completed without throwing, clear attachments + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch (_error) { + // Don't clear on error - user may want to retry + } + }); + }; + + // Render with or without local provider + const inner = ( + <> +