From f6e20632df682f7565944e56d024e1ea8a33e4ad Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:54:44 -0700 Subject: [PATCH 01/32] docs: add design spec for Channels tab + seamless connector setup Co-Authored-By: Claude Opus 4.6 (1M context) --- ...channels-tab-and-connector-setup-design.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-27-channels-tab-and-connector-setup-design.md diff --git a/docs/superpowers/specs/2026-03-27-channels-tab-and-connector-setup-design.md b/docs/superpowers/specs/2026-03-27-channels-tab-and-connector-setup-design.md new file mode 100644 index 00000000..0c167a94 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-channels-tab-and-connector-setup-design.md @@ -0,0 +1,120 @@ +# Channels Tab + Seamless Connector Setup + +## Goal + +Add a Channels tab to every agent for bidirectional messaging (iMessage, Slack, WhatsApp, SMS), and build per-connector step-by-step setup guides in the wizard with one-click OAuth where available. + +## Sub-project 1: Channels Tab on Every Agent + +### What it does + +A new "Channels" tab on the agent detail page showing messaging interfaces to reach the agent. Users can connect/disconnect channels from the UI. Appears on ALL agents. + +### Frontend + +Add a 7th tab "Channels" to the agent detail view in `AgentsPage.tsx`. Content: + +- Header: "Talk to this agent from" + "+ Add Channel" button +- Channel list: iMessage, Slack, WhatsApp, SMS (Twilio) +- Each channel shows: icon, name, description, status badge (Active/Not connected) +- Connected channels expand to show: monitored contact/workspace, instructions, Stop/Settings buttons +- Disconnected channels show a "Connect" button + +**"+ Add Channel" / "Connect" flow:** +- Opens a modal/inline form specific to the channel type: + - **iMessage:** prompt for phone number or contact to monitor → starts daemon + - **Slack:** prompt for bot token → registers webhook + - **WhatsApp:** prompt for access token + phone number ID → registers webhook + - **SMS:** prompt for Twilio account SID + auth token + phone number → registers webhook + +### Backend + +- `POST /v1/managed-agents/{id}/channels` — bind a channel (creates binding + starts daemon/webhook) +- `DELETE /v1/managed-agents/{id}/channels/{binding_id}` — unbind (stops daemon/webhook) +- `AgentManager.add_channel_binding()` already exists — wire it to the new endpoint +- For iMessage: the endpoint starts/stops the `imessage_daemon` via the existing `run_daemon`/`stop_daemon` functions +- For Slack/WhatsApp/SMS: the endpoint updates `ChannelBridge` channel config + +### No new daemon code + +`imessage_daemon.py`, `ChannelBridge`, `webhook_routes.py`, and `channels_cmd.py` already exist. This sub-project wires them to the frontend UI. + +--- + +## Sub-project 2: Seamless Connector Setup + +### What it does + +Replace the current generic "paste token" connect flow with per-connector step-by-step guides. Two patterns: + +**Pattern A — Step-by-step guide (for token/password auth):** +Numbered steps with direct links to settings pages, then paste credentials. + +**Pattern B — One-click OAuth + manual fallback:** +Primary "Authorize" button opens the service in browser. Manual token paste as secondary option. + +### Per-connector flows + +| Connector | Pattern | UX | +|-----------|---------|-----| +| Gmail IMAP | A | Steps: enable 2FA → generate app password → paste email:password | +| Outlook | A | Steps: enable 2FA → generate app password → paste email:password | +| Slack | B | "Open Slack App Settings" button → paste bot token (xoxb-...) | +| Notion | B | "Open Notion Integrations" button → paste token (ntn_...) → remind to share pages | +| Granola | A | Steps: open Granola app → Settings → API → paste key (grn_...) | +| Google Drive | B | "Authorize" opens OAuth consent URL → localhost callback captures code → exchanges for tokens | +| Google Calendar | B | Same as Drive (shared OAuth client, different scope) | +| Google Contacts | B | Same as Drive (shared OAuth client, different scope) | +| Obsidian | Filesystem | Path input field | +| Apple Notes | Auto | Check Full Disk Access, auto-connect | +| iMessage | Auto | Check Full Disk Access, auto-connect | + +### Google OAuth proper flow (new) + +The current Gmail OAuth connector saves the raw auth code without exchanging it. Fix: + +1. Add `OAuthCallbackServer` — a tiny localhost HTTP server at `http://localhost:8789/callback` that: + - Waits for Google to redirect with `?code=...` + - Exchanges the code for access_token + refresh_token via `POST https://oauth2.googleapis.com/token` + - Stores tokens in `~/.openjarvis/connectors/{connector}.json` + - Returns a "Success! You can close this tab" page to the browser + +2. Update `oauth.py` with `exchange_google_token(code, client_id, client_secret, redirect_uri)` function + +3. Update `handle_callback()` in Gmail, Drive, Calendar, Contacts connectors to use the exchange flow + +4. Google OAuth client ID/secret stored in `~/.openjarvis/config.toml` under `[connectors.google]` — user provides their own from Google Cloud Console + +### Frontend changes + +In `SourceConnectFlow.tsx`, replace the generic OAuth panel with connector-specific panels: + +- Each connector gets its own instructions component +- Step-by-step connectors show numbered cards with links +- OAuth connectors show the "Authorize" button + manual fallback input +- All connectors show a "Read-only access · No data leaves your device" badge + +### What does NOT change + +- SourcePicker (already lists all connectors) +- IngestDashboard (already shows progress) +- ReadyScreen (already shows suggestions) +- Backend connector sync logic (already works) +- KnowledgeStore / ingestion pipeline + +## Test Plan + +### Channels Tab +- Channels tab appears on all agents (not just DeepResearch) +- Binding iMessage channel starts daemon and shows "Active" status +- Unbinding stops daemon +- Channel status updates in real-time +- Multiple agents can bind different channels + +### Connector Setup +- Gmail IMAP: step-by-step flow → paste credentials → connected +- Notion: one-click opens integration page → paste token → connected +- Slack: paste bot token → connected +- Google Drive: OAuth consent URL opens → localhost callback exchanges token → connected +- Setup wizard completes with all connected sources ingested +- Read-only guarantee maintained across all connectors From 1e37235d0dd2eb295d5818da6363fc219f4ef86d Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:01:05 -0700 Subject: [PATCH 02/32] docs: add implementation plan for Channels tab + connector setup UX Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-03-27-channels-tab-and-connector-setup.md | 1006 +++++++++++++++++ 1 file changed, 1006 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-27-channels-tab-and-connector-setup.md diff --git a/docs/superpowers/plans/2026-03-27-channels-tab-and-connector-setup.md b/docs/superpowers/plans/2026-03-27-channels-tab-and-connector-setup.md new file mode 100644 index 00000000..a210f3da --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-channels-tab-and-connector-setup.md @@ -0,0 +1,1006 @@ +# Channels Tab + Connector Setup 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:** Add a Channels tab to every agent for bidirectional messaging (iMessage, Slack, WhatsApp, SMS), and build per-connector step-by-step setup guides with one-click OAuth in the setup wizard. + +**Architecture:** Sub-project 1 adds a React Channels tab component to AgentsPage.tsx that calls existing backend endpoints (POST/DELETE/GET channel bindings). Sub-project 2 replaces the generic OAuth panel in SourceConnectFlow.tsx with per-connector instruction panels and adds a Google OAuth callback server in Python. + +**Tech Stack:** React 19, TypeScript, Tailwind CSS, Python 3.10+, FastAPI, Click + +--- + +## Sub-project 1: Channels Tab + +### Task 1: Add Channels tab to agent detail view + +**Files:** +- Modify: `frontend/src/pages/AgentsPage.tsx` +- Modify: `frontend/src/lib/api.ts` + +- [ ] **Step 1: Add API functions for channel binding CRUD** + +In `frontend/src/lib/api.ts`, add after the existing `fetchAgentChannels` function: + +```typescript +export async function bindAgentChannel( + agentId: string, + channelType: string, + config?: Record, +): Promise { + const res = await fetch( + `${getBase()}/v1/managed-agents/${agentId}/channels`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channel_type: channelType, + config: config || {}, + routing_mode: 'dedicated', + }), + }, + ); + if (!res.ok) throw new Error(`Failed: ${res.status}`); + return res.json(); +} + +export async function unbindAgentChannel( + agentId: string, + bindingId: string, +): Promise { + const res = await fetch( + `${getBase()}/v1/managed-agents/${agentId}/channels/${bindingId}`, + { method: 'DELETE' }, + ); + if (!res.ok) throw new Error(`Failed: ${res.status}`); +} +``` + +- [ ] **Step 2: Add 'channels' to DETAIL_TABS in AgentsPage.tsx** + +Find the `DETAIL_TABS` array (line ~1315) and add a channels entry. Also find the `Wifi` icon import from `lucide-react` at the top of the file: + +Add to imports: +```typescript +import { Wifi } from 'lucide-react'; +``` + +Update `DETAIL_TABS`: +```typescript +const DETAIL_TABS = [ + { id: 'overview', label: 'Overview', icon: Activity }, + { id: 'interact', label: 'Interact', icon: MessageSquare }, + { id: 'channels', label: 'Channels', icon: Wifi }, + { id: 'tasks', label: 'Tasks', icon: ListTodo }, + { id: 'memory', label: 'Memory', icon: Brain }, + { id: 'learning', label: 'Learning', icon: Settings }, + { id: 'logs', label: 'Logs', icon: FileText }, +] as const; +``` + +Update the `detailTab` state type to include `'channels'`: +```typescript +const [detailTab, setDetailTab] = useState< + 'overview' | 'interact' | 'channels' | 'tasks' | 'memory' | 'learning' | 'logs' +>('overview'); +``` + +- [ ] **Step 3: Add ChannelsTab component** + +Add this component inside `AgentsPage.tsx` (before the main `AgentsPage` component). This is a self-contained component that manages its own state: + +```typescript +const AVAILABLE_CHANNELS = [ + { + type: 'imessage', + name: 'iMessage', + icon: '💬', + description: 'Text from your iPhone, iPad, or Mac', + connectLabel: 'Phone number or contact to monitor', + placeholder: '+15551234567', + }, + { + type: 'slack', + name: 'Slack', + icon: '#', + description: 'Message from any Slack workspace', + connectLabel: 'Slack bot token (xoxb-...)', + placeholder: 'xoxb-...', + }, + { + type: 'whatsapp', + name: 'WhatsApp', + icon: '📱', + description: 'Message via WhatsApp', + connectLabel: 'WhatsApp access token', + placeholder: 'Access token', + }, + { + type: 'twilio', + name: 'SMS (Twilio)', + icon: '📨', + description: 'Text from any phone via Twilio', + connectLabel: 'Twilio phone number', + placeholder: '+15551234567', + }, +]; + +function ChannelsTab({ agentId }: { agentId: string }) { + const [bindings, setBindings] = useState([]); + const [connectingType, setConnectingType] = useState(null); + const [inputValue, setInputValue] = useState(''); + const [loading, setLoading] = useState(false); + + const loadBindings = useCallback(() => { + fetchAgentChannels(agentId).then(setBindings).catch(() => setBindings([])); + }, [agentId]); + + useEffect(() => { loadBindings(); }, [loadBindings]); + + const handleConnect = async (channelType: string) => { + if (!inputValue.trim()) return; + setLoading(true); + try { + await bindAgentChannel(agentId, channelType, { + identifier: inputValue.trim(), + }); + setConnectingType(null); + setInputValue(''); + loadBindings(); + } catch { + // Could show error toast + } finally { + setLoading(false); + } + }; + + const handleDisconnect = async (bindingId: string) => { + try { + await unbindAgentChannel(agentId, bindingId); + loadBindings(); + } catch { + // Could show error toast + } + }; + + const boundTypes = new Set(bindings.map((b) => b.channel_type)); + + return ( +
+
+

+ Talk to this agent from +

+
+ + {AVAILABLE_CHANNELS.map((ch) => { + const binding = bindings.find((b) => b.channel_type === ch.type); + const isConnecting = connectingType === ch.type; + + return ( +
+
+
+ {ch.icon} +
+
+
{ch.name}
+
+ {ch.description} +
+
+ {binding ? ( + + Active + + ) : ( + + )} +
+ + {/* Expanded: connected details */} + {binding && ( +
+
+ {binding.config?.identifier + ? `Monitoring: ${binding.config.identifier}` + : `Session: ${binding.session_id}`} +
+ +
+ )} + + {/* Expanded: connect form */} + {isConnecting && ( +
+
+ {ch.connectLabel} +
+
+ setInputValue(e.target.value)} + placeholder={ch.placeholder} + style={{ + flex: 1, padding: '6px 10px', + background: 'var(--color-bg-secondary)', + border: '1px solid var(--color-border)', + borderRadius: 4, color: 'var(--color-text)', + fontSize: 12, + }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleConnect(ch.type); + }} + /> + +
+
+ )} +
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 4: Wire the tab content rendering** + +In the tab content rendering section (around line 1410-1525), add between the existing tab conditionals: + +```typescript +{detailTab === 'channels' && ( + +)} +``` + +- [ ] **Step 5: Add missing imports** + +At the top of `AgentsPage.tsx`, ensure these are imported from `api.ts`: + +```typescript +import { + // ... existing imports + bindAgentChannel, + unbindAgentChannel, +} from '../lib/api'; +``` + +- [ ] **Step 6: Verify TypeScript compiles** + +```bash +cd frontend && npx tsc --noEmit 2>&1 | head -20 +``` + +Expected: No errors. + +- [ ] **Step 7: Build frontend** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Build succeeds. + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/pages/AgentsPage.tsx frontend/src/lib/api.ts +git commit -m "feat: add Channels tab to agent detail view with connect/disconnect UI" +``` + +--- + +### Task 2: Start iMessage daemon when binding iMessage channel + +**Files:** +- Modify: `src/openjarvis/server/agent_manager_routes.py` + +The backend `bind_channel` endpoint currently just creates a DB record. For iMessage, it also needs to start the daemon. For other channels, it just stores the config. + +- [ ] **Step 1: Modify the bind_channel endpoint to start iMessage daemon** + +Find the `bind_channel` endpoint (around line 956). Replace it with: + +```python +@agents_router.post("/{agent_id}/channels") +async def bind_channel(agent_id: str, req: BindChannelRequest): + if not manager.get_agent(agent_id): + raise HTTPException(status_code=404, detail="Agent not found") + binding = manager.bind_channel( + agent_id, + channel_type=req.channel_type, + config=req.config, + routing_mode=req.routing_mode, + ) + + # Start iMessage daemon if binding iMessage + if req.channel_type == "imessage": + identifier = (req.config or {}).get("identifier", "") + if identifier: + try: + from openjarvis.channels.imessage_daemon import ( + is_running, + run_daemon, + ) + + if not is_running(): + import threading + + engine = getattr(request.app.state, "engine", None) + if engine: + from openjarvis.server.agent_manager_routes import ( + _build_deep_research_tools, + ) + + tools = _build_deep_research_tools( + engine=engine, model="", + ) + if tools: + from openjarvis.agents.deep_research import ( + DeepResearchAgent, + ) + + agent = DeepResearchAgent( + engine=engine, + model=getattr(engine, "_model", ""), + tools=tools, + ) + + def handler(text: str) -> str: + result = agent.run(text) + return result.content or "No results." + + t = threading.Thread( + target=run_daemon, + kwargs={ + "chat_identifier": identifier, + "handler": handler, + }, + daemon=True, + ) + t.start() + except Exception as exc: + logger.warning( + "Failed to start iMessage daemon: %s", exc, + ) + + return binding +``` + +Note: This requires adding `request: Request` to the function signature: + +```python +@agents_router.post("/{agent_id}/channels") +async def bind_channel( + agent_id: str, req: BindChannelRequest, request: Request, +): +``` + +- [ ] **Step 2: Stop daemon on unbind** + +Find the `unbind_channel` endpoint (around line 967). Add daemon stop before deleting: + +```python +@agents_router.delete("/{agent_id}/channels/{binding_id}") +async def unbind_channel(agent_id: str, binding_id: str): + # Stop iMessage daemon if applicable + binding = manager._get_binding(binding_id) + if binding and binding.get("channel_type") == "imessage": + try: + from openjarvis.channels.imessage_daemon import stop_daemon + stop_daemon() + except Exception: + pass + manager.unbind_channel(binding_id) + return {"status": "unbound"} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/openjarvis/server/agent_manager_routes.py +git commit -m "feat: start/stop iMessage daemon on channel bind/unbind" +``` + +--- + +### Task 3: E2E test — Channels tab in browser + +**Files:** None (manual testing) + +- [ ] **Step 1: Start server and open browser** + +```bash +uv run jarvis serve --host 127.0.0.1 --port 8222 +``` + +Open `http://127.0.0.1:8222`. + +- [ ] **Step 2: Create a DeepResearch agent and check Channels tab** + +1. Go to Agents tab → create from "Personal Deep Research" template +2. Open the agent → click "Channels" tab +3. Verify 4 channels listed: iMessage, Slack, WhatsApp, SMS +4. All should show "Connect" buttons + +- [ ] **Step 3: Connect iMessage channel** + +1. Click "Connect" on iMessage +2. Enter a phone number +3. Click "Connect" +4. Verify it shows "Active" with the phone number + +- [ ] **Step 4: Disconnect** + +1. Click "Disconnect" on the iMessage binding +2. Verify it returns to "Connect" state + +- [ ] **Step 5: Push** + +```bash +git push origin main +``` + +--- + +## Sub-project 2: Connector Setup Instructions + +### Task 4: Add per-connector instruction data + +**Files:** +- Modify: `frontend/src/types/connectors.ts` + +- [ ] **Step 1: Extend ConnectorMeta with setup instructions** + +In `frontend/src/types/connectors.ts`, add a `steps` field to the SOURCE_CATALOG entries. Each step has a `label`, optional `url` (opens in browser), and optional `inputField`: + +```typescript +export interface SetupStep { + label: string; + url?: string; + urlLabel?: string; +} + +export interface ConnectorMeta { + connector_id: string; + display_name: string; + auth_type: 'oauth' | 'local' | 'bridge' | 'filesystem'; + category: string; + icon: string; + color: string; + description: string; + steps?: SetupStep[]; + inputFields?: Array<{ + name: string; + placeholder: string; + type?: 'text' | 'password'; + }>; +} +``` + +- [ ] **Step 2: Add per-connector steps to SOURCE_CATALOG** + +Update the existing SOURCE_CATALOG entries with setup instructions: + +```typescript +export const SOURCE_CATALOG: ConnectorMeta[] = [ + { + connector_id: 'gmail_imap', + display_name: 'Gmail (IMAP)', + auth_type: 'oauth', + category: 'communication', + icon: 'Mail', + color: 'text-red-400', + description: 'Email via app password', + steps: [ + { + label: 'Enable 2-Factor Authentication on your Google account', + url: 'https://myaccount.google.com/signinoptions/two-step-verification', + urlLabel: 'Open Google Security', + }, + { + label: 'Generate an App Password for "Mail"', + url: 'https://myaccount.google.com/apppasswords', + urlLabel: 'Open App Passwords', + }, + { label: 'Paste your credentials below' }, + ], + inputFields: [ + { name: 'email', placeholder: 'Email address', type: 'text' }, + { name: 'password', placeholder: 'App password (xxxx xxxx xxxx xxxx)', type: 'password' }, + ], + }, + { + connector_id: 'slack', + display_name: 'Slack', + auth_type: 'oauth', + category: 'communication', + icon: 'Hash', + color: 'text-purple-400', + description: 'Channel messages and threads', + steps: [ + { + label: 'Go to your Slack App settings and copy the Bot User OAuth Token', + url: 'https://api.slack.com/apps', + urlLabel: 'Open Slack Apps', + }, + { label: 'Paste the bot token below (starts with xoxb-)' }, + ], + inputFields: [ + { name: 'token', placeholder: 'xoxb-...', type: 'password' }, + ], + }, + { + connector_id: 'notion', + display_name: 'Notion', + auth_type: 'oauth', + category: 'documents', + icon: 'FileText', + color: 'text-gray-300', + description: 'Pages and databases', + steps: [ + { + label: 'Create an internal integration and copy the secret', + url: 'https://www.notion.so/profile/integrations', + urlLabel: 'Open Notion Integrations', + }, + { label: 'Paste the integration token below (starts with ntn_)' }, + { label: 'Then share pages with your integration: Page → ... → Connections → Add' }, + ], + inputFields: [ + { name: 'token', placeholder: 'ntn_...', type: 'password' }, + ], + }, + { + connector_id: 'granola', + display_name: 'Granola', + auth_type: 'oauth', + category: 'documents', + icon: 'Mic', + color: 'text-amber-400', + description: 'AI meeting notes', + steps: [ + { label: 'Open the Granola desktop app → Settings → API' }, + { label: 'Copy your API key and paste below' }, + ], + inputFields: [ + { name: 'token', placeholder: 'grn_...', type: 'password' }, + ], + }, + { + connector_id: 'gmail', + display_name: 'Gmail', + auth_type: 'oauth', + category: 'communication', + icon: 'Mail', + color: 'text-red-400', + description: 'Email messages and threads (OAuth)', + steps: [ + { label: 'Click "Authorize" to open Google consent screen' }, + { label: 'Grant read-only access to your Gmail' }, + ], + }, + { + connector_id: 'imessage', + display_name: 'iMessage', + auth_type: 'local', + category: 'communication', + icon: 'MessageSquare', + color: 'text-green-400', + description: 'macOS Messages history', + steps: [ + { label: 'Open System Settings → Privacy & Security → Full Disk Access' }, + { label: 'Enable access for your terminal app or OpenJarvis' }, + ], + }, + { + connector_id: 'obsidian', + display_name: 'Obsidian', + auth_type: 'filesystem', + category: 'documents', + icon: 'FolderOpen', + color: 'text-purple-300', + description: 'Markdown vault', + steps: [ + { label: 'Enter the path to your Obsidian vault folder' }, + ], + inputFields: [ + { name: 'path', placeholder: '/Users/you/Documents/MyVault', type: 'text' }, + ], + }, + { + connector_id: 'gdrive', + display_name: 'Google Drive', + auth_type: 'oauth', + category: 'documents', + icon: 'FolderOpen', + color: 'text-blue-400', + description: 'Docs, Sheets, and files', + steps: [ + { label: 'Click "Authorize" to grant read-only access to Google Drive' }, + ], + }, + { + connector_id: 'gcalendar', + display_name: 'Calendar', + auth_type: 'oauth', + category: 'pim', + icon: 'Calendar', + color: 'text-blue-400', + description: 'Events and meetings', + steps: [ + { label: 'Click "Authorize" to grant read-only access to Google Calendar' }, + ], + }, + { + connector_id: 'gcontacts', + display_name: 'Contacts', + auth_type: 'oauth', + category: 'pim', + icon: 'Users', + color: 'text-blue-400', + description: 'People and contact info', + steps: [ + { label: 'Click "Authorize" to grant read-only access to Google Contacts' }, + ], + }, +]; +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/types/connectors.ts +git commit -m "feat: add per-connector setup instructions and input fields to SOURCE_CATALOG" +``` + +--- + +### Task 5: Build StepByStepPanel component for SourceConnectFlow + +**Files:** +- Modify: `frontend/src/components/setup/SourceConnectFlow.tsx` + +- [ ] **Step 1: Add the StepByStepPanel component** + +Add this component inside `SourceConnectFlow.tsx`, before the main `SourceConnectFlow` component. It replaces the generic OAuth panel for connectors that have `steps` defined: + +```typescript +function StepByStepPanel({ + connector, + onConnect, + onSkip, + isConnecting, +}: { + connector: ConnectorMeta; + onConnect: (req: ConnectRequest) => void; + onSkip: () => void; + isConnecting: boolean; +}) { + const [inputs, setInputs] = useState>({}); + const steps = connector.steps || []; + const fields = connector.inputFields || []; + + const updateInput = (name: string, value: string) => { + setInputs((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = () => { + const req: ConnectRequest = {}; + for (const field of fields) { + if (field.name === 'email') req.email = inputs.email; + else if (field.name === 'password') req.password = inputs.password; + else if (field.name === 'token') req.token = inputs.token; + else if (field.name === 'path') req.path = inputs.path; + } + // For email+password connectors, also set token as email:password + if (req.email && req.password) { + req.token = `${req.email}:${req.password}`; + req.code = req.token; + } + if (req.token && !req.code) { + req.code = req.token; + } + onConnect(req); + }; + + const allFilled = fields.every((f) => inputs[f.name]?.trim()); + + return ( +
+
+ + {connector.icon === 'Mail' ? '✉️' : + connector.icon === 'Hash' ? '#️⃣' : + connector.icon === 'FileText' ? '📄' : + connector.icon === 'Mic' ? '🎙️' : + connector.icon === 'FolderOpen' ? '📁' : '🔗'} + + + {connector.display_name} + +
+ + {steps.map((step, i) => ( +
+
+ STEP {i + 1} +
+
+ {step.label} +
+ {step.url && ( + + {step.urlLabel || 'Open'} → + + )} +
+ ))} + + {fields.length > 0 && ( +
+ {fields.map((field) => ( + updateInput(field.name, e.target.value)} + placeholder={field.placeholder} + type={field.type || 'text'} + style={{ + width: '100%', + padding: '8px 10px', + background: 'var(--color-bg-secondary)', + border: '1px solid var(--color-border)', + borderRadius: 4, + color: 'var(--color-text)', + fontSize: 13, + marginBottom: 8, + boxSizing: 'border-box', + }} + /> + ))} +
+ )} + +
+ 🔒 Read-only access · No data leaves your device +
+ +
+ + +
+
+ ); +} +``` + +- [ ] **Step 2: Wire StepByStepPanel into the connect flow** + +In the `SourceConnectFlow` component, find where it renders the OAuth/Local/Filesystem panels based on `auth_type`. Modify the rendering logic to check if the connector has `steps` defined, and if so, use `StepByStepPanel` instead of the generic panels: + +```typescript +// In the panel rendering section, add before the existing auth_type checks: +const connectorMeta = SOURCE_CATALOG.find( + (c) => c.connector_id === activeSource.connector_id, +); + +// If connector has steps, use StepByStepPanel regardless of auth_type +if (connectorMeta?.steps) { + return ( + handleConnect(activeSource.connector_id, req)} + onSkip={() => handleSkip(activeIndex)} + isConnecting={activeSource.state === 'connecting'} + /> + ); +} + +// Fallback to existing generic panels for connectors without steps +``` + +- [ ] **Step 3: Import ConnectorMeta and SOURCE_CATALOG** + +Ensure these are imported at the top of `SourceConnectFlow.tsx`: + +```typescript +import { ConnectorMeta, SOURCE_CATALOG, ConnectRequest } from '../../types/connectors'; +``` + +- [ ] **Step 4: Verify TypeScript compiles and build** + +```bash +cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -5 +``` + +Expected: No errors, build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/setup/SourceConnectFlow.tsx +git commit -m "feat: add StepByStepPanel with per-connector setup instructions" +``` + +--- + +### Task 6: E2E test — setup wizard with new connector flow + +**Files:** None (manual testing) + +- [ ] **Step 1: Open the setup wizard** + +Navigate to the setup wizard (or trigger it from the agent creation flow). + +- [ ] **Step 2: Test Gmail IMAP connection** + +1. Select "Gmail (IMAP)" in source picker +2. Verify 3 numbered steps appear with links to Google settings +3. Verify email + password input fields appear +4. Enter credentials and click Connect +5. Verify connection succeeds + +- [ ] **Step 3: Test Notion connection** + +1. Select "Notion" +2. Verify steps show link to Notion Integrations page +3. Verify token input field +4. Paste token, click Connect + +- [ ] **Step 4: Test Slack connection** + +1. Select "Slack" +2. Verify steps show link to Slack Apps page +3. Paste bot token, click Connect + +- [ ] **Step 5: Verify ingest dashboard works with connected sources** + +After connecting sources, verify the IngestDashboard shows sync progress for each. + +- [ ] **Step 6: Push** + +```bash +git push origin main +``` From 1e6b7f181a1ddece68012b04eb79de84326e0aa6 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:03:14 -0700 Subject: [PATCH 03/32] feat: start/stop iMessage daemon on channel bind/unbind Co-Authored-By: Claude Sonnet 4.6 --- src/openjarvis/server/agent_manager_routes.py | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/openjarvis/server/agent_manager_routes.py b/src/openjarvis/server/agent_manager_routes.py index 09ffb65d..f944503b 100644 --- a/src/openjarvis/server/agent_manager_routes.py +++ b/src/openjarvis/server/agent_manager_routes.py @@ -954,18 +954,78 @@ def create_agent_manager_router( return {"bindings": manager.list_channel_bindings(agent_id)} @agents_router.post("/{agent_id}/channels") - async def bind_channel(agent_id: str, req: BindChannelRequest): + async def bind_channel( + agent_id: str, req: BindChannelRequest, request: Request, + ): if not manager.get_agent(agent_id): raise HTTPException(status_code=404, detail="Agent not found") - return manager.bind_channel( + binding = manager.bind_channel( agent_id, channel_type=req.channel_type, config=req.config, routing_mode=req.routing_mode, ) + # Start iMessage daemon if binding iMessage + if req.channel_type == "imessage": + identifier = (req.config or {}).get("identifier", "") + if identifier: + try: + from openjarvis.channels.imessage_daemon import ( + is_running, + run_daemon, + ) + + if not is_running(): + import threading + + engine = getattr(request.app.state, "engine", None) + if engine: + from openjarvis.server.agent_manager_routes import ( + _build_deep_research_tools, + ) + + tools = _build_deep_research_tools(engine=engine, model="") + if tools: + from openjarvis.agents.deep_research import ( + DeepResearchAgent, + ) + + agent_inst = DeepResearchAgent( + engine=engine, + model=getattr(engine, "_model", ""), + tools=tools, + ) + + def handler(text: str) -> str: + result = agent_inst.run(text) + return result.content or "No results." + + t = threading.Thread( + target=run_daemon, + kwargs={ + "chat_identifier": identifier, + "handler": handler, + }, + daemon=True, + ) + t.start() + except Exception as exc: + logger.warning("Failed to start iMessage daemon: %s", exc) + + return binding + @agents_router.delete("/{agent_id}/channels/{binding_id}") async def unbind_channel(agent_id: str, binding_id: str): + # Stop iMessage daemon if applicable + try: + binding = manager._get_binding(binding_id) + if binding and binding.get("channel_type") == "imessage": + from openjarvis.channels.imessage_daemon import stop_daemon + + stop_daemon() + except Exception: + pass manager.unbind_channel(binding_id) return {"status": "unbound"} From 5aa5cd8c9e81306badc129ad3e2b8b63441eb672 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:04:12 -0700 Subject: [PATCH 04/32] feat: add Channels tab to agent detail view with connect/disconnect UI Adds iMessage, Slack, WhatsApp, and SMS (Twilio) channel binding UI to the agent detail view, with expandable connect forms and active/disconnect states. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/api.ts | 32 ++++ frontend/src/pages/AgentsPage.tsx | 246 +++++++++++++++++++++++++++++- 2 files changed, 277 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9dc4b238..a140695e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -434,6 +434,38 @@ export async function fetchAgentChannels(agentId: string): Promise, +): Promise { + const res = await fetch( + `${getBase()}/v1/managed-agents/${agentId}/channels`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channel_type: channelType, + config: config || {}, + routing_mode: 'dedicated', + }), + }, + ); + if (!res.ok) throw new Error(`Failed: ${res.status}`); + return res.json(); +} + +export async function unbindAgentChannel( + agentId: string, + bindingId: string, +): Promise { + const res = await fetch( + `${getBase()}/v1/managed-agents/${agentId}/channels/${bindingId}`, + { method: 'DELETE' }, + ); + if (!res.ok) throw new Error(`Failed: ${res.status}`); +} + export async function fetchTemplates(): Promise { const res = await fetch(`${getBase()}/v1/templates`); if (!res.ok) throw new Error(`Failed: ${res.status}`); diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index 0f7288fa..dfdac0cf 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -5,6 +5,8 @@ import { fetchManagedAgents, fetchAgentTasks, fetchAgentChannels, + bindAgentChannel, + unbindAgentChannel, fetchAgentMessages, fetchTemplates, createManagedAgent, @@ -46,6 +48,7 @@ import { ChevronRight, Send, RefreshCw, + Wifi, } from 'lucide-react'; // --------------------------------------------------------------------------- @@ -990,6 +993,241 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s ); } +// --------------------------------------------------------------------------- +// Channels tab component +// --------------------------------------------------------------------------- + +const AVAILABLE_CHANNELS = [ + { + type: 'imessage', + name: 'iMessage', + icon: '💬', + description: 'Text from your iPhone, iPad, or Mac', + connectLabel: 'Phone number or contact to monitor', + placeholder: '+15551234567', + }, + { + type: 'slack', + name: 'Slack', + icon: '#', + description: 'Message from any Slack workspace', + connectLabel: 'Slack bot token (xoxb-...)', + placeholder: 'xoxb-...', + }, + { + type: 'whatsapp', + name: 'WhatsApp', + icon: '📱', + description: 'Message via WhatsApp', + connectLabel: 'WhatsApp access token', + placeholder: 'Access token', + }, + { + type: 'twilio', + name: 'SMS (Twilio)', + icon: '📨', + description: 'Text from any phone via Twilio', + connectLabel: 'Twilio phone number', + placeholder: '+15551234567', + }, +]; + +function ChannelsTab({ agentId }: { agentId: string }) { + const [bindings, setBindings] = useState([]); + const [connectingType, setConnectingType] = useState(null); + const [inputValue, setInputValue] = useState(''); + const [loading, setLoading] = useState(false); + + const loadBindings = useCallback(() => { + fetchAgentChannels(agentId).then(setBindings).catch(() => setBindings([])); + }, [agentId]); + + useEffect(() => { loadBindings(); }, [loadBindings]); + + const handleConnect = async (channelType: string) => { + if (!inputValue.trim()) return; + setLoading(true); + try { + await bindAgentChannel(agentId, channelType, { + identifier: inputValue.trim(), + }); + setConnectingType(null); + setInputValue(''); + loadBindings(); + } catch { + // Could show error toast + } finally { + setLoading(false); + } + }; + + const handleDisconnect = async (bindingId: string) => { + try { + await unbindAgentChannel(agentId, bindingId); + loadBindings(); + } catch { + // Could show error toast + } + }; + + return ( +
+
+

+ Talk to this agent from +

+
+ + {AVAILABLE_CHANNELS.map((ch) => { + const binding = bindings.find((b) => b.channel_type === ch.type); + const isConnecting = connectingType === ch.type; + + return ( +
+
+
+ {ch.icon} +
+
+
{ch.name}
+
+ {ch.description} +
+
+ {binding ? ( + + Active + + ) : ( + + )} +
+ + {/* Expanded: connected details */} + {binding && ( +
+
+ {binding.config?.identifier + ? `Monitoring: ${binding.config.identifier}` + : `Session: ${binding.session_id}`} +
+ +
+ )} + + {/* Expanded: connect form */} + {isConnecting && ( +
+
+ {ch.connectLabel} +
+
+ setInputValue(e.target.value)} + placeholder={ch.placeholder} + style={{ + flex: 1, padding: '6px 10px', + background: 'var(--color-bg-secondary)', + border: '1px solid var(--color-border)', + borderRadius: 4, color: 'var(--color-text)', + fontSize: 12, + }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleConnect(ch.type); + }} + /> + +
+
+ )} +
+ ); + })} +
+ ); +} + // --------------------------------------------------------------------------- // Learning tab component // --------------------------------------------------------------------------- @@ -1185,7 +1423,7 @@ export function AgentsPage() { const [channels, setChannels] = useState([]); const [templates, setTemplates] = useState([]); const [showWizard, setShowWizard] = useState(false); - const [detailTab, setDetailTab] = useState<'overview' | 'interact' | 'tasks' | 'memory' | 'learning' | 'logs'>('overview'); + const [detailTab, setDetailTab] = useState<'overview' | 'interact' | 'channels' | 'tasks' | 'memory' | 'learning' | 'logs'>('overview'); const refresh = useCallback(async () => { try { @@ -1315,6 +1553,7 @@ export function AgentsPage() { const DETAIL_TABS = [ { id: 'overview', label: 'Overview', icon: Activity }, { id: 'interact', label: 'Interact', icon: MessageSquare }, + { id: 'channels', label: 'Channels', icon: Wifi }, { id: 'tasks', label: 'Tasks', icon: ListTodo }, { id: 'memory', label: 'Memory', icon: Brain }, { id: 'learning', label: 'Learning', icon: Settings }, @@ -1468,6 +1707,11 @@ export function AgentsPage() { {/* Tab: Interact */} {detailTab === 'interact' && } + {/* Tab: Channels */} + {detailTab === 'channels' && ( + + )} + {/* Tab: Tasks */} {detailTab === 'tasks' && (
From 639c6416181713cafaeac9e4a12c0429701866b8 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:05:55 -0700 Subject: [PATCH 05/32] feat: add per-connector setup instructions and input fields to SOURCE_CATALOG Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/setup/SourcePicker.tsx | 4 +- frontend/src/types/connectors.ts | 194 +++++++++++++++++- 2 files changed, 185 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/setup/SourcePicker.tsx b/frontend/src/components/setup/SourcePicker.tsx index 57199d78..b95ed8ef 100644 --- a/frontend/src/components/setup/SourcePicker.tsx +++ b/frontend/src/components/setup/SourcePicker.tsx @@ -11,7 +11,7 @@ import { Users, CheckCircle2, } from 'lucide-react'; -import { SOURCE_CATALOG, type SourceCard } from '../../types/connectors'; +import { SOURCE_CATALOG, type ConnectorMeta, type SourceCard } from '../../types/connectors'; // --------------------------------------------------------------------------- // Icon map @@ -42,7 +42,7 @@ function CategorySection({ }: { category: string; label: string; - cards: SourceCard[]; + cards: ConnectorMeta[]; selected: Set; onToggle: (id: string) => void; }) { diff --git a/frontend/src/types/connectors.ts b/frontend/src/types/connectors.ts index cb5306b2..7e1861f2 100644 --- a/frontend/src/types/connectors.ts +++ b/frontend/src/types/connectors.ts @@ -1,3 +1,25 @@ +export interface SetupStep { + label: string; + url?: string; + urlLabel?: string; +} + +export interface ConnectorMeta { + connector_id: string; + display_name: string; + auth_type: 'oauth' | 'local' | 'bridge' | 'filesystem'; + category: 'communication' | 'documents' | 'pim'; + icon: string; + color: string; + description: string; + steps?: SetupStep[]; + inputFields?: Array<{ + name: string; + placeholder: string; + type?: 'text' | 'password'; + }>; +} + export interface ConnectorInfo { connector_id: string; display_name: string; @@ -35,15 +57,165 @@ export interface SourceCard { description: string; } -export const SOURCE_CATALOG: SourceCard[] = [ - { connector_id: "gmail", display_name: "Gmail", auth_type: "oauth", category: "communication", icon: "Mail", color: "text-red-400", description: "Email messages and threads" }, - { connector_id: "gmail_imap", display_name: "Gmail (IMAP)", auth_type: "oauth", category: "communication", icon: "Mail", color: "text-red-400", description: "Email via app password" }, - { connector_id: "slack", display_name: "Slack", auth_type: "oauth", category: "communication", icon: "Hash", color: "text-purple-400", description: "Channel messages and threads" }, - { connector_id: "imessage", display_name: "iMessage", auth_type: "local", category: "communication", icon: "MessageSquare", color: "text-green-400", description: "macOS Messages history" }, - { connector_id: "gdrive", display_name: "Google Drive", auth_type: "oauth", category: "documents", icon: "FolderOpen", color: "text-blue-400", description: "Docs, Sheets, and files" }, - { connector_id: "notion", display_name: "Notion", auth_type: "oauth", category: "documents", icon: "FileText", color: "text-gray-300", description: "Pages and databases" }, - { connector_id: "obsidian", display_name: "Obsidian", auth_type: "filesystem", category: "documents", icon: "Diamond", color: "text-violet-400", description: "Markdown vault" }, - { connector_id: "granola", display_name: "Granola", auth_type: "oauth", category: "documents", icon: "Mic", color: "text-amber-400", description: "AI meeting notes" }, - { connector_id: "gcalendar", display_name: "Calendar", auth_type: "oauth", category: "pim", icon: "Calendar", color: "text-blue-400", description: "Events and meetings" }, - { connector_id: "gcontacts", display_name: "Contacts", auth_type: "oauth", category: "pim", icon: "Users", color: "text-blue-400", description: "People and contact info" }, +export const SOURCE_CATALOG: ConnectorMeta[] = [ + { + connector_id: 'gmail_imap', + display_name: 'Gmail (IMAP)', + auth_type: 'oauth', + category: 'communication', + icon: 'Mail', + color: 'text-red-400', + description: 'Email via app password', + steps: [ + { + label: 'Enable 2-Factor Authentication on your Google account', + url: 'https://myaccount.google.com/signinoptions/two-step-verification', + urlLabel: 'Open Google Security', + }, + { + label: 'Generate an App Password for "Mail"', + url: 'https://myaccount.google.com/apppasswords', + urlLabel: 'Open App Passwords', + }, + { label: 'Paste your credentials below' }, + ], + inputFields: [ + { name: 'email', placeholder: 'Email address', type: 'text' }, + { name: 'password', placeholder: 'App password (xxxx xxxx xxxx xxxx)', type: 'password' }, + ], + }, + { + connector_id: 'slack', + display_name: 'Slack', + auth_type: 'oauth', + category: 'communication', + icon: 'Hash', + color: 'text-purple-400', + description: 'Channel messages and threads', + steps: [ + { + label: 'Go to your Slack App settings and copy the Bot User OAuth Token', + url: 'https://api.slack.com/apps', + urlLabel: 'Open Slack Apps', + }, + { label: 'Paste the bot token below (starts with xoxb-)' }, + ], + inputFields: [ + { name: 'token', placeholder: 'xoxb-...', type: 'password' }, + ], + }, + { + connector_id: 'notion', + display_name: 'Notion', + auth_type: 'oauth', + category: 'documents', + icon: 'FileText', + color: 'text-gray-300', + description: 'Pages and databases', + steps: [ + { + label: 'Create an internal integration and copy the secret', + url: 'https://www.notion.so/profile/integrations', + urlLabel: 'Open Notion Integrations', + }, + { label: 'Paste the integration token below (starts with ntn_)' }, + { label: 'Then share pages with your integration: Page → ... → Connections → Add' }, + ], + inputFields: [ + { name: 'token', placeholder: 'ntn_...', type: 'password' }, + ], + }, + { + connector_id: 'granola', + display_name: 'Granola', + auth_type: 'oauth', + category: 'documents', + icon: 'Mic', + color: 'text-amber-400', + description: 'AI meeting notes', + steps: [ + { label: 'Open the Granola desktop app → Settings → API' }, + { label: 'Copy your API key and paste below' }, + ], + inputFields: [ + { name: 'token', placeholder: 'grn_...', type: 'password' }, + ], + }, + { + connector_id: 'gmail', + display_name: 'Gmail', + auth_type: 'oauth', + category: 'communication', + icon: 'Mail', + color: 'text-red-400', + description: 'Email messages and threads (OAuth)', + steps: [ + { label: 'Click "Authorize" to open Google consent screen' }, + { label: 'Grant read-only access to your Gmail' }, + ], + }, + { + connector_id: 'imessage', + display_name: 'iMessage', + auth_type: 'local', + category: 'communication', + icon: 'MessageSquare', + color: 'text-green-400', + description: 'macOS Messages history', + steps: [ + { label: 'Open System Settings → Privacy & Security → Full Disk Access' }, + { label: 'Enable access for your terminal app or OpenJarvis' }, + ], + }, + { + connector_id: 'obsidian', + display_name: 'Obsidian', + auth_type: 'filesystem', + category: 'documents', + icon: 'FolderOpen', + color: 'text-purple-300', + description: 'Markdown vault', + steps: [ + { label: 'Enter the path to your Obsidian vault folder' }, + ], + inputFields: [ + { name: 'path', placeholder: '/Users/you/Documents/MyVault', type: 'text' }, + ], + }, + { + connector_id: 'gdrive', + display_name: 'Google Drive', + auth_type: 'oauth', + category: 'documents', + icon: 'FolderOpen', + color: 'text-blue-400', + description: 'Docs, Sheets, and files', + steps: [ + { label: 'Click "Authorize" to grant read-only access to Google Drive' }, + ], + }, + { + connector_id: 'gcalendar', + display_name: 'Calendar', + auth_type: 'oauth', + category: 'pim', + icon: 'Calendar', + color: 'text-blue-400', + description: 'Events and meetings', + steps: [ + { label: 'Click "Authorize" to grant read-only access to Google Calendar' }, + ], + }, + { + connector_id: 'gcontacts', + display_name: 'Contacts', + auth_type: 'oauth', + category: 'pim', + icon: 'Users', + color: 'text-blue-400', + description: 'People and contact info', + steps: [ + { label: 'Click "Authorize" to grant read-only access to Google Contacts' }, + ], + }, ]; From af53d51e58947928049fc6ddda62d24cdb7c8c02 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:07:03 -0700 Subject: [PATCH 06/32] feat: add StepByStepPanel with per-connector setup instructions Adds StepByStepPanel component to SourceConnectFlow that renders numbered setup steps with optional links and input fields for each connector. Wires it as the primary panel for any connector with steps defined, with existing OAuth/Local/Filesystem panels as fallbacks. Also updates connectors.ts to use ConnectorMeta (with SetupStep/inputFields) as the canonical type and exports SourceCard as a backward-compatible alias. Co-Authored-By: Claude Sonnet 4.6 --- .../components/setup/SourceConnectFlow.tsx | 181 +++++++++++++++++- frontend/src/types/connectors.ts | 11 +- 2 files changed, 182 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/setup/SourceConnectFlow.tsx b/frontend/src/components/setup/SourceConnectFlow.tsx index 7ca3f52d..b977b275 100644 --- a/frontend/src/components/setup/SourceConnectFlow.tsx +++ b/frontend/src/components/setup/SourceConnectFlow.tsx @@ -9,7 +9,7 @@ import { } from 'lucide-react'; import { SOURCE_CATALOG } from '../../types/connectors'; import { connectSource } from '../../lib/connectors-api'; -import type { ConnectRequest } from '../../types/connectors'; +import type { ConnectRequest, ConnectorMeta } from '../../types/connectors'; // --------------------------------------------------------------------------- // Types @@ -285,6 +285,178 @@ function LocalPanel({ ); } +// --------------------------------------------------------------------------- +// StepByStepPanel — per-connector numbered setup instructions +// --------------------------------------------------------------------------- + +function StepByStepPanel({ + connector, + onConnect, + onSkip, + isConnecting, +}: { + connector: ConnectorMeta; + onConnect: (req: ConnectRequest) => void; + onSkip: () => void; + isConnecting: boolean; +}) { + const [inputs, setInputs] = useState>({}); + const steps = connector.steps || []; + const fields = connector.inputFields || []; + + const updateInput = (name: string, value: string) => { + setInputs((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = () => { + const req: ConnectRequest = {}; + for (const field of fields) { + if (field.name === 'email') req.email = inputs.email; + else if (field.name === 'password') req.password = inputs.password; + else if (field.name === 'token') req.token = inputs.token; + else if (field.name === 'path') req.path = inputs.path; + } + // For email+password connectors, also set token as email:password + if (req.email && req.password) { + req.token = `${req.email}:${req.password}`; + req.code = req.token; + } + if (req.token && !req.code) { + req.code = req.token; + } + onConnect(req); + }; + + const allFilled = fields.every((f) => inputs[f.name]?.trim()); + + return ( +
+
+ + {connector.icon === 'Mail' ? '\u2709\uFE0F' : + connector.icon === 'Hash' ? '#\uFE0F\u20E3' : + connector.icon === 'FileText' ? '\uD83D\uDCC4' : + connector.icon === 'Mic' ? '\uD83C\uDF99\uFE0F' : + connector.icon === 'FolderOpen' ? '\uD83D\uDCC1' : '\uD83D\uDD17'} + + + {connector.display_name} + +
+ + {steps.map((step, i) => ( +
+
+ STEP {i + 1} +
+
+ {step.label} +
+ {step.url && ( + + {step.urlLabel || 'Open'} → + + )} +
+ ))} + + {fields.length > 0 && ( +
+ {fields.map((field) => ( + updateInput(field.name, e.target.value)} + placeholder={field.placeholder} + type={field.type || 'text'} + style={{ + width: '100%', + padding: '8px 10px', + background: 'var(--color-bg-secondary)', + border: '1px solid var(--color-border)', + borderRadius: 4, + color: 'var(--color-text)', + fontSize: 13, + marginBottom: 8, + boxSizing: 'border-box', + }} + /> + ))} +
+ )} + +
+ Read-only access · No data leaves your device +
+ +
+ + +
+
+ ); +} + // --------------------------------------------------------------------------- // SourceConnectFlow // --------------------------------------------------------------------------- @@ -392,6 +564,13 @@ export function SourceConnectFlow({ Connected
+ ) : activeCard.steps ? ( + handleConnect(activeEntry.id, req)} + onSkip={() => handleSkip(activeEntry.id)} + isConnecting={activeEntry.state === 'connecting'} + /> ) : activeCard.auth_type === 'filesystem' ? ( Date: Fri, 27 Mar 2026 15:32:51 -0700 Subject: [PATCH 07/32] docs: add design spec for Channels + Messaging tabs (replacing old Channels tab) Co-Authored-By: Claude Opus 4.6 (1M context) --- ...3-27-channels-and-messaging-tabs-design.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-27-channels-and-messaging-tabs-design.md diff --git a/docs/superpowers/specs/2026-03-27-channels-and-messaging-tabs-design.md b/docs/superpowers/specs/2026-03-27-channels-and-messaging-tabs-design.md new file mode 100644 index 00000000..84999969 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-channels-and-messaging-tabs-design.md @@ -0,0 +1,85 @@ +# Channels + Messaging Tabs — Unified Agent Setup + +## Goal + +Replace the current disconnected setup experience with two new tabs on every agent detail page: **Channels** (data sources the agent can search) and **Messaging** (ways to talk to the agent from your phone). Clicking "+ Add" on an unconnected source opens step-by-step setup inline. + +## Tab Structure + +``` +Overview | Interact | Channels | Messaging | Tasks | Memory | Learning | Logs +``` + +### Channels Tab + +Shows all data source connectors with their connection status and chunk counts. + +**Connected sources** — green border, checkmark, chunk count: +- Gmail (1,076 chunks) ✓ +- iMessage (52,956 chunks) ✓ +- Granola (1,144 chunks) ✓ +- etc. + +**Not connected** — dashed border, "+ Add" button: +- Google Drive — Not connected [+ Add] +- Calendar — Not connected [+ Add] +- Outlook — Not connected [+ Add] + +Clicking "+ Add" expands inline with the StepByStepPanel (per-connector instructions, links to settings pages, input fields for credentials). Same flow currently in the setup wizard but embedded in the tab. + +**Data shown per connected source:** +- Icon + name +- Chunk count (from KnowledgeStore) +- Connection status +- Disconnect option (on hover/click) + +### Messaging Tab + +Shows ways to reach the agent from your phone or other platforms. + +**Active channels** — green border, clear instructions: +- iMessage: "Text +1 (408) 981-9553 from your iPhone" [Active] +- Slack: "DM @jarvis in your workspace" [Active] + +**Not set up** — dashed border, "Set Up" button: +- WhatsApp — Not set up [Set Up] +- SMS (Twilio) — Not set up [Set Up] + +The key UX principle: **tell the user what to do, don't ask them for config.** Active channels show the action ("Text this number"), not the plumbing ("Enter phone number to monitor"). + +### What Changes vs Current + +| Before | After | +|--------|-------| +| Separate "Channels" tab with confusing "phone number to monitor" prompt | **Messaging** tab with clear "Text this number" instructions | +| Setup wizard only during onboarding, hidden after | **Channels** tab shows all sources with inline "+ Add" | +| No way to see connected data sources on agent page | **Channels** tab shows all with chunk counts | +| Connector setup disconnected from agent | Unified on the agent page | + +### What Does NOT Change + +- Overview tab (stats, config, instruction) +- Interact tab (chat) +- Tasks, Memory, Learning, Logs tabs +- Backend API endpoints (already exist) +- Connector sync logic +- iMessage daemon / ChannelBridge / webhook routes + +### Backend Requirements + +One new endpoint needed: + +`GET /v1/connectors/status` — returns all connectors with connection status AND chunk counts from KnowledgeStore. The Channels tab calls this to populate the grid. + +Everything else (bind/unbind channel, connect/disconnect source, sync status) already exists. + +## Test Plan + +- Channels tab shows connected sources with chunk counts +- Channels tab shows unconnected sources with "+ Add" +- Clicking "+ Add" shows step-by-step instructions inline +- Completing setup adds the source and starts ingestion +- Messaging tab shows active channels with clear action text +- Messaging tab shows "Set Up" for inactive channels +- Setting up iMessage starts the daemon +- Both tabs appear on ALL agents, not just DeepResearch From 6cc33cb2948d7fdd21e038abcd8fe09eabfa1bd9 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:36:14 -0700 Subject: [PATCH 08/32] docs: add implementation plan for Channels + Messaging tabs v2 Co-Authored-By: Claude Opus 4.6 (1M context) --- ...26-03-27-channels-and-messaging-tabs-v2.md | 672 ++++++++++++++++++ 1 file changed, 672 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-27-channels-and-messaging-tabs-v2.md diff --git a/docs/superpowers/plans/2026-03-27-channels-and-messaging-tabs-v2.md b/docs/superpowers/plans/2026-03-27-channels-and-messaging-tabs-v2.md new file mode 100644 index 00000000..e66a6af4 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-channels-and-messaging-tabs-v2.md @@ -0,0 +1,672 @@ +# Channels + Messaging Tabs Implementation Plan (v2) + +> **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:** Replace the existing Channels tab with two tabs — **Channels** (data sources with chunk counts and inline "+ Add" setup) and **Messaging** (clear "Text this number from your iPhone" instructions) — on every agent detail page. + +**Architecture:** Refactor the existing `ChannelsTab` component in `AgentsPage.tsx` into two separate components. The Channels tab calls existing connector API endpoints + a new chunk-count endpoint. The Messaging tab reuses the existing channel binding API. The `StepByStepPanel` from `SourceConnectFlow.tsx` is extracted for reuse in the Channels tab inline setup. + +**Tech Stack:** React 19, TypeScript, existing FastAPI endpoints + +--- + +### Task 1: Add connector chunk counts to backend API + +**Files:** +- Modify: `src/openjarvis/server/connectors_router.py` + +The Channels tab needs to show chunk counts per source. Add a `chunks` field to the connector summary by querying the KnowledgeStore. + +- [ ] **Step 1: Modify `_connector_summary` to include chunk count** + +In `src/openjarvis/server/connectors_router.py`, find the `_connector_summary` function (line ~102). Add a chunk count from the KnowledgeStore: + +```python +def _connector_summary(connector_id: str, instance: Any) -> Dict[str, Any]: + """Build the dict returned by GET /connectors.""" + chunks = 0 + try: + from openjarvis.connectors.store import KnowledgeStore + store = KnowledgeStore() + rows = store._conn.execute( + "SELECT COUNT(*) FROM knowledge_chunks WHERE source = ?", + (connector_id,), + ).fetchone() + chunks = rows[0] if rows else 0 + except Exception: + pass + + return { + "connector_id": connector_id, + "display_name": getattr(instance, "display_name", connector_id), + "auth_type": getattr(instance, "auth_type", "unknown"), + "connected": instance.is_connected(), + "chunks": chunks, + } +``` + +- [ ] **Step 2: Verify endpoint returns chunks** + +```bash +uv run python3 -c " +import httpx +r = httpx.get('http://127.0.0.1:8222/connectors', timeout=5) +for c in r.json().get('connectors', [])[:5]: + print(f'{c[\"connector_id\"]}: connected={c[\"connected\"]}, chunks={c.get(\"chunks\", \"?\")}') +" +``` + +- [ ] **Step 3: Lint + commit** + +```bash +uv run ruff check src/openjarvis/server/connectors_router.py +git add src/openjarvis/server/connectors_router.py +git commit -m "feat: include chunk counts in connector list API response" +``` + +--- + +### Task 2: Rewrite Channels tab as data sources view + +**Files:** +- Modify: `frontend/src/pages/AgentsPage.tsx` +- Modify: `frontend/src/lib/connectors-api.ts` + +Replace the existing `ChannelsTab` (messaging channels) with a new `ChannelsTab` (data sources). + +- [ ] **Step 1: Update ConnectorInfo type in connectors-api.ts to include chunks** + +In `frontend/src/lib/connectors-api.ts`, find the `ConnectorInfo` type (or wherever it's defined) and add `chunks`: + +If there's an interface in `types/connectors.ts`: +```typescript +export interface ConnectorInfo { + connector_id: string; + display_name: string; + auth_type: 'oauth' | 'local' | 'bridge' | 'filesystem'; + connected: boolean; + auth_url?: string; + mcp_tools?: string[]; + chunks?: number; // ADD THIS +} +``` + +- [ ] **Step 2: Replace the ChannelsTab component** + +In `AgentsPage.tsx`, find the existing `AVAILABLE_CHANNELS` array and `ChannelsTab` component (around lines 1000-1170). Replace the entire block with a new `ChannelsTab` that shows data sources: + +```typescript +import { SOURCE_CATALOG, ConnectorMeta, ConnectRequest } from '../types/connectors'; +import { listConnectors, connectSource } from '../lib/connectors-api'; + +function ChannelsTab({ agentId }: { agentId: string }) { + const [connectors, setConnectors] = useState< + Array<{ connector_id: string; display_name: string; connected: boolean; chunks: number }> + >([]); + const [expandedId, setExpandedId] = useState(null); + const [loading, setLoading] = useState(false); + + const loadConnectors = useCallback(() => { + listConnectors() + .then((list) => + setConnectors( + list.map((c) => ({ + connector_id: c.connector_id, + display_name: c.display_name, + connected: c.connected, + chunks: (c as any).chunks || 0, + })), + ), + ) + .catch(() => {}); + }, []); + + useEffect(() => { loadConnectors(); }, [loadConnectors]); + + const handleConnect = async (id: string, req: ConnectRequest) => { + setLoading(true); + try { + await connectSource(id, req); + setExpandedId(null); + loadConnectors(); + } catch { + // error handling + } finally { + setLoading(false); + } + }; + + const connected = connectors.filter((c) => c.connected); + const notConnected = connectors.filter((c) => !c.connected); + + // Merge with SOURCE_CATALOG for icons/descriptions + const getMeta = (id: string) => + SOURCE_CATALOG.find((s) => s.connector_id === id); + + const iconMap: Record = { + gmail: '✉️', gmail_imap: '✉️', slack: '#', + imessage: '💬', gdrive: '📁', notion: '📄', + obsidian: '📁', granola: '🎙️', gcalendar: '📅', + gcontacts: '📇', outlook: '✉️', apple_notes: '🍎', + dropbox: '📦', whatsapp: '📱', + }; + + return ( +
+
+ Data sources your agent can search +
+ + {/* Connected sources grid */} + {connected.length > 0 && ( +
+ {connected.map((c) => ( +
+ {iconMap[c.connector_id] || '🔗'} +
+
+ {c.display_name} +
+
+ {c.chunks.toLocaleString()} chunks +
+
+ +
+ ))} +
+ )} + + {/* Not connected grid */} + {notConnected.length > 0 && ( +
+ {notConnected.map((c) => { + const meta = getMeta(c.connector_id); + const isExpanded = expandedId === c.connector_id; + + return ( +
+
+ setExpandedId(isExpanded ? null : c.connector_id) + } + > + {iconMap[c.connector_id] || '🔗'} +
+
+ {c.display_name} +
+
+ Not connected +
+
+ + {isExpanded ? '✕ Close' : '+ Add'} + +
+ + {/* Inline setup panel */} + {isExpanded && meta?.steps && ( +
+ {meta.steps.map((step, i) => ( +
+
+ STEP {i + 1} +
+
+ {step.label} +
+ {step.url && ( + + {step.urlLabel || 'Open'} → + + )} +
+ ))} + {meta.inputFields && ( + + handleConnect(c.connector_id, req) + } + /> + )} +
+ 🔒 Read-only access · No data leaves your device +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} + +function InlineConnectForm({ + fields, + loading, + onSubmit, +}: { + fields: Array<{ name: string; placeholder: string; type?: string }>; + loading: boolean; + onSubmit: (req: ConnectRequest) => void; +}) { + const [inputs, setInputs] = useState>({}); + + const update = (name: string, value: string) => + setInputs((p) => ({ ...p, [name]: value })); + + const allFilled = fields.every((f) => inputs[f.name]?.trim()); + + const submit = () => { + const req: ConnectRequest = {}; + for (const f of fields) { + if (f.name === 'email') req.email = inputs.email; + else if (f.name === 'password') req.password = inputs.password; + else if (f.name === 'token') req.token = inputs.token; + else if (f.name === 'path') req.path = inputs.path; + } + if (req.email && req.password) { + req.token = `${req.email}:${req.password}`; + req.code = req.token; + } + if (req.token && !req.code) req.code = req.token; + onSubmit(req); + }; + + return ( +
+ {fields.map((f) => ( + update(f.name, e.target.value)} + placeholder={f.placeholder} + type={f.type || 'text'} + style={{ + width: '100%', padding: '7px 10px', + background: 'var(--color-bg)', + border: '1px solid var(--color-border)', + borderRadius: 4, color: 'var(--color-text)', + fontSize: 12, marginBottom: 6, + boxSizing: 'border-box', + }} + /> + ))} + +
+ ); +} +``` + +- [ ] **Step 3: Update DETAIL_TABS to rename and add Messaging** + +Find `DETAIL_TABS` (line ~1553). Update to: + +```typescript +const DETAIL_TABS = [ + { id: 'overview', label: 'Overview', icon: Activity }, + { id: 'interact', label: 'Interact', icon: MessageSquare }, + { id: 'channels', label: 'Channels', icon: Database }, + { id: 'messaging', label: 'Messaging', icon: Wifi }, + { id: 'tasks', label: 'Tasks', icon: ListTodo }, + { id: 'memory', label: 'Memory', icon: Brain }, + { id: 'learning', label: 'Learning', icon: Settings }, + { id: 'logs', label: 'Logs', icon: FileText }, +] as const; +``` + +Add `Database` to lucide-react imports. Update detailTab type: + +```typescript +const [detailTab, setDetailTab] = useState< + 'overview' | 'interact' | 'channels' | 'messaging' | 'tasks' | 'memory' | 'learning' | 'logs' +>('overview'); +``` + +- [ ] **Step 4: Add MessagingTab component and wire both tabs** + +Add the `MessagingTab` component (reuses the old `AVAILABLE_CHANNELS` data but with clear UX): + +```typescript +const MESSAGING_CHANNELS = [ + { + type: 'imessage', name: 'iMessage', icon: '💬', + description: 'Text from your iPhone, iPad, or Mac', + activeTemplate: (id: string) => `Text ${id} from your iPhone`, + setupLabel: 'Phone number or email for the agent to use', + placeholder: '+15551234567', + }, + { + type: 'slack', name: 'Slack', icon: '#', + description: 'Message from any Slack workspace', + activeTemplate: (id: string) => `DM @jarvis in ${id}`, + setupLabel: 'Slack bot token (xoxb-...)', + placeholder: 'xoxb-...', + }, + { + type: 'whatsapp', name: 'WhatsApp', icon: '📱', + description: 'Message via WhatsApp', + activeTemplate: (id: string) => `Message ${id} on WhatsApp`, + setupLabel: 'WhatsApp access token', + placeholder: 'Access token', + }, + { + type: 'twilio', name: 'SMS (Twilio)', icon: '📨', + description: 'Text from any phone via Twilio', + activeTemplate: (id: string) => `Text ${id} from any phone`, + setupLabel: 'Twilio phone number', + placeholder: '+15551234567', + }, +]; + +function MessagingTab({ agentId }: { agentId: string }) { + const [bindings, setBindings] = useState([]); + const [setupType, setSetupType] = useState(null); + const [inputValue, setInputValue] = useState(''); + const [loading, setLoading] = useState(false); + + const loadBindings = useCallback(() => { + fetchAgentChannels(agentId).then(setBindings).catch(() => setBindings([])); + }, [agentId]); + + useEffect(() => { loadBindings(); }, [loadBindings]); + + const handleSetup = async (channelType: string) => { + if (!inputValue.trim()) return; + setLoading(true); + try { + await bindAgentChannel(agentId, channelType, { + identifier: inputValue.trim(), + }); + setSetupType(null); + setInputValue(''); + loadBindings(); + } catch { /* */ } finally { setLoading(false); } + }; + + const handleRemove = async (bindingId: string) => { + try { + await unbindAgentChannel(agentId, bindingId); + loadBindings(); + } catch { /* */ } + }; + + return ( +
+
+ Talk to your agent from your phone or other platforms +
+ + {MESSAGING_CHANNELS.map((ch) => { + const binding = bindings.find((b) => b.channel_type === ch.type); + const identifier = binding?.config?.identifier as string || binding?.session_id || ''; + const isSetup = setupType === ch.type; + + return ( +
+
+ {ch.icon} +
+
{ch.name}
+
+ {binding + ? ch.activeTemplate(identifier) + : 'Not set up'} +
+
+ {binding ? ( +
+ Active + +
+ ) : ( + + )} +
+ + {isSetup && ( +
+
{ch.setupLabel}
+
+ setInputValue(e.target.value)} + placeholder={ch.placeholder} + style={{ + flex: 1, padding: '6px 10px', + background: 'var(--color-bg-secondary)', + border: '1px solid var(--color-border)', + borderRadius: 4, color: 'var(--color-text)', + fontSize: 12, + }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSetup(ch.type); + }} + /> + +
+
+ )} +
+ ); + })} +
+ ); +} +``` + +Wire both tabs in the content rendering section: + +```typescript +{detailTab === 'channels' && ( + +)} +{detailTab === 'messaging' && ( + +)} +``` + +- [ ] **Step 5: Add required imports** + +At the top of `AgentsPage.tsx`: + +```typescript +import { Database } from 'lucide-react'; +import { SOURCE_CATALOG } from '../types/connectors'; +import type { ConnectRequest } from '../types/connectors'; +import { listConnectors, connectSource } from '../lib/connectors-api'; +``` + +- [ ] **Step 6: TypeScript check + build** + +```bash +cd frontend && npx tsc --noEmit 2>&1 | head -20 +cd frontend && npm run build 2>&1 | tail -10 +``` + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/pages/AgentsPage.tsx frontend/src/lib/connectors-api.ts frontend/src/types/connectors.ts +git commit -m "feat: replace Channels tab with data sources + add Messaging tab" +``` + +--- + +### Task 3: E2E test both tabs + +**Files:** None (manual testing) + +- [ ] **Step 1: Restart server to pick up new static files** + +```bash +# Kill existing server, rebuild frontend, restart +kill $(pgrep -f "jarvis serve") 2>/dev/null +cd frontend && npm run build +uv run jarvis serve --port 8222 --model qwen3.5:9b & +``` + +- [ ] **Step 2: Test Channels tab** + +1. Open http://127.0.0.1:8222 → Agents → open agent +2. Click "Channels" tab +3. Verify connected sources show with green border + chunk counts +4. Verify unconnected sources show with dashed border + "+ Add" +5. Click "+ Add" on an unconnected source → verify step-by-step instructions appear inline + +- [ ] **Step 3: Test Messaging tab** + +1. Click "Messaging" tab +2. Verify active channels show with "Text +1... from your iPhone" instructions +3. Verify inactive channels show "Set Up" button +4. Click "Set Up" → verify input field appears with clear label + +- [ ] **Step 4: Push** + +```bash +git push origin feat/deep-research-setup +``` From 7cdbf531438893a030182277a205887362b38fd6 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:38:04 -0700 Subject: [PATCH 09/32] feat: include chunk counts in connector list API response Co-Authored-By: Claude Sonnet 4.6 --- src/openjarvis/server/connectors_router.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/openjarvis/server/connectors_router.py b/src/openjarvis/server/connectors_router.py index e28cec40..e549e241 100644 --- a/src/openjarvis/server/connectors_router.py +++ b/src/openjarvis/server/connectors_router.py @@ -101,11 +101,25 @@ def create_connectors_router(): def _connector_summary(connector_id: str, instance: Any) -> Dict[str, Any]: """Build the dict returned by GET /connectors.""" + chunks = 0 + try: + from openjarvis.connectors.store import KnowledgeStore + + store = KnowledgeStore() + rows = store._conn.execute( + "SELECT COUNT(*) FROM knowledge_chunks WHERE source = ?", + (connector_id,), + ).fetchone() + chunks = rows[0] if rows else 0 + except Exception: + pass + return { "connector_id": connector_id, "display_name": getattr(instance, "display_name", connector_id), "auth_type": getattr(instance, "auth_type", "unknown"), "connected": instance.is_connected(), + "chunks": chunks, } # ------------------------------------------------------------------ From ae73eca91a4338bb144397173eca7c0c73566910 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:40:50 -0700 Subject: [PATCH 10/32] feat: replace Channels tab with data sources + add Messaging tab Rewrite ChannelsTab to show connector data sources (Gmail, Slack, Notion, etc.) with chunk counts, connected/not-connected states, and inline setup with step-by-step instructions. Add new MessagingTab for phone/platform messaging (iMessage, Slack, WhatsApp, SMS). Update DETAIL_TABS to include both tabs with Database and Wifi icons respectively. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AgentsPage.tsx | 478 +++++++++++++++++++++++------- frontend/src/types/connectors.ts | 1 + 2 files changed, 367 insertions(+), 112 deletions(-) diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index dfdac0cf..c8adeb19 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -49,7 +49,11 @@ import { Send, RefreshCw, Wifi, + Database, } from 'lucide-react'; +import { SOURCE_CATALOG } from '../types/connectors'; +import type { ConnectRequest } from '../types/connectors'; +import { listConnectors, connectSource } from '../lib/connectors-api'; // --------------------------------------------------------------------------- // Status helpers @@ -994,47 +998,328 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s } // --------------------------------------------------------------------------- -// Channels tab component +// Channels tab component (data sources) // --------------------------------------------------------------------------- -const AVAILABLE_CHANNELS = [ +function ChannelsTab({ agentId }: { agentId: string }) { + const [connectors, setConnectors] = useState< + Array<{ connector_id: string; display_name: string; connected: boolean; chunks: number }> + >([]); + const [expandedId, setExpandedId] = useState(null); + const [loading, setLoading] = useState(false); + + // suppress unused var – agentId reserved for future per-agent source binding + void agentId; + + const loadConnectors = useCallback(() => { + listConnectors() + .then((list) => + setConnectors( + list.map((c) => ({ + connector_id: c.connector_id, + display_name: c.display_name, + connected: c.connected, + chunks: (c as any).chunks || 0, + })), + ), + ) + .catch(() => {}); + }, []); + + useEffect(() => { loadConnectors(); }, [loadConnectors]); + + const handleConnect = async (id: string, req: ConnectRequest) => { + setLoading(true); + try { + await connectSource(id, req); + setExpandedId(null); + loadConnectors(); + } catch { + // error handling + } finally { + setLoading(false); + } + }; + + const connected = connectors.filter((c) => c.connected); + const notConnected = connectors.filter((c) => !c.connected); + + // Merge with SOURCE_CATALOG for icons/descriptions + const getMeta = (id: string) => + SOURCE_CATALOG.find((s) => s.connector_id === id); + + const iconMap: Record = { + gmail: '\u2709\uFE0F', gmail_imap: '\u2709\uFE0F', slack: '#', + imessage: '\uD83D\uDCAC', gdrive: '\uD83D\uDCC1', notion: '\uD83D\uDCC4', + obsidian: '\uD83D\uDCC1', granola: '\uD83C\uDF99\uFE0F', gcalendar: '\uD83D\uDCC5', + gcontacts: '\uD83D\uDCC7', outlook: '\u2709\uFE0F', apple_notes: '\uD83C\uDF4E', + dropbox: '\uD83D\uDCE6', whatsapp: '\uD83D\uDCF1', + }; + + return ( +
+
+ Data sources your agent can search +
+ + {/* Connected sources grid */} + {connected.length > 0 && ( +
+ {connected.map((c) => ( +
+ {iconMap[c.connector_id] || '\uD83D\uDD17'} +
+
+ {c.display_name} +
+
+ {c.chunks.toLocaleString()} chunks +
+
+ {'\u2713'} +
+ ))} +
+ )} + + {/* Not connected grid */} + {notConnected.length > 0 && ( +
+ {notConnected.map((c) => { + const meta = getMeta(c.connector_id); + const isExpanded = expandedId === c.connector_id; + + return ( +
+
+ setExpandedId(isExpanded ? null : c.connector_id) + } + > + {iconMap[c.connector_id] || '\uD83D\uDD17'} +
+
+ {c.display_name} +
+
+ Not connected +
+
+ + {isExpanded ? '\u2715 Close' : '+ Add'} + +
+ + {/* Inline setup panel */} + {isExpanded && meta?.steps && ( +
+ {meta.steps.map((step, i) => ( +
+
+ STEP {i + 1} +
+
+ {step.label} +
+ {step.url && ( + + {step.urlLabel || 'Open'} {'\u2192'} + + )} +
+ ))} + {meta.inputFields && ( + + handleConnect(c.connector_id, req) + } + /> + )} +
+ {'\uD83D\uDD12'} Read-only access {'\u00B7'} No data leaves your device +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} + +function InlineConnectForm({ + fields, + loading, + onSubmit, +}: { + fields: Array<{ name: string; placeholder: string; type?: string }>; + loading: boolean; + onSubmit: (req: ConnectRequest) => void; +}) { + const [inputs, setInputs] = useState>({}); + + const update = (name: string, value: string) => + setInputs((p) => ({ ...p, [name]: value })); + + const allFilled = fields.every((f) => inputs[f.name]?.trim()); + + const submit = () => { + const req: ConnectRequest = {}; + for (const f of fields) { + if (f.name === 'email') req.email = inputs.email; + else if (f.name === 'password') req.password = inputs.password; + else if (f.name === 'token') req.token = inputs.token; + else if (f.name === 'path') req.path = inputs.path; + } + if (req.email && req.password) { + req.token = `${req.email}:${req.password}`; + req.code = req.token; + } + if (req.token && !req.code) req.code = req.token; + onSubmit(req); + }; + + return ( +
+ {fields.map((f) => ( + update(f.name, e.target.value)} + placeholder={f.placeholder} + type={f.type || 'text'} + style={{ + width: '100%', padding: '7px 10px', + background: 'var(--color-bg)', + border: '1px solid var(--color-border)', + borderRadius: 4, color: 'var(--color-text)', + fontSize: 12, marginBottom: 6, + boxSizing: 'border-box', + }} + /> + ))} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Messaging tab component +// --------------------------------------------------------------------------- + +const MESSAGING_CHANNELS = [ { - type: 'imessage', - name: 'iMessage', - icon: '💬', + type: 'imessage', name: 'iMessage', icon: '\uD83D\uDCAC', description: 'Text from your iPhone, iPad, or Mac', - connectLabel: 'Phone number or contact to monitor', + activeTemplate: (id: string) => `Text ${id} from your iPhone`, + setupLabel: 'Phone number or email for the agent to use', placeholder: '+15551234567', }, { - type: 'slack', - name: 'Slack', - icon: '#', + type: 'slack', name: 'Slack', icon: '#', description: 'Message from any Slack workspace', - connectLabel: 'Slack bot token (xoxb-...)', + activeTemplate: (id: string) => `DM @jarvis in ${id}`, + setupLabel: 'Slack bot token (xoxb-...)', placeholder: 'xoxb-...', }, { - type: 'whatsapp', - name: 'WhatsApp', - icon: '📱', + type: 'whatsapp', name: 'WhatsApp', icon: '\uD83D\uDCF1', description: 'Message via WhatsApp', - connectLabel: 'WhatsApp access token', + activeTemplate: (id: string) => `Message ${id} on WhatsApp`, + setupLabel: 'WhatsApp access token', placeholder: 'Access token', }, { - type: 'twilio', - name: 'SMS (Twilio)', - icon: '📨', + type: 'twilio', name: 'SMS (Twilio)', icon: '\uD83D\uDCE8', description: 'Text from any phone via Twilio', - connectLabel: 'Twilio phone number', + activeTemplate: (id: string) => `Text ${id} from any phone`, + setupLabel: 'Twilio phone number', placeholder: '+15551234567', }, ]; -function ChannelsTab({ agentId }: { agentId: string }) { +function MessagingTab({ agentId }: { agentId: string }) { const [bindings, setBindings] = useState([]); - const [connectingType, setConnectingType] = useState(null); + const [setupType, setSetupType] = useState(null); const [inputValue, setInputValue] = useState(''); const [loading, setLoading] = useState(false); @@ -1044,152 +1329,115 @@ function ChannelsTab({ agentId }: { agentId: string }) { useEffect(() => { loadBindings(); }, [loadBindings]); - const handleConnect = async (channelType: string) => { + const handleSetup = async (channelType: string) => { if (!inputValue.trim()) return; setLoading(true); try { await bindAgentChannel(agentId, channelType, { identifier: inputValue.trim(), }); - setConnectingType(null); + setSetupType(null); setInputValue(''); loadBindings(); - } catch { - // Could show error toast - } finally { - setLoading(false); - } + } catch { /* */ } finally { setLoading(false); } }; - const handleDisconnect = async (bindingId: string) => { + const handleRemove = async (bindingId: string) => { try { await unbindAgentChannel(agentId, bindingId); loadBindings(); - } catch { - // Could show error toast - } + } catch { /* */ } }; return (
-

- Talk to this agent from -

+ Talk to your agent from your phone or other platforms
- {AVAILABLE_CHANNELS.map((ch) => { + {MESSAGING_CHANNELS.map((ch) => { const binding = bindings.find((b) => b.channel_type === ch.type); - const isConnecting = connectingType === ch.type; + const identifier = binding?.config?.identifier as string || binding?.session_id || ''; + const isSetup = setupType === ch.type; return (
-
- {ch.icon} -
+ {ch.icon}
-
{ch.name}
-
{ch.name}
+
- {ch.description} + {binding + ? ch.activeTemplate(identifier) + : 'Not set up'}
{binding ? ( - - Active - +
+ Active + +
) : ( )}
- {/* Expanded: connected details */} - {binding && ( + {isSetup && (
- {binding.config?.identifier - ? `Monitoring: ${binding.config.identifier}` - : `Session: ${binding.session_id}`} -
- -
- )} - - {/* Expanded: connect form */} - {isConnecting && ( -
-
- {ch.connectLabel} -
-
+ }}>{ch.setupLabel}
+
setInputValue(e.target.value)} @@ -1202,11 +1450,11 @@ function ChannelsTab({ agentId }: { agentId: string }) { fontSize: 12, }} onKeyDown={(e) => { - if (e.key === 'Enter') handleConnect(ch.type); + if (e.key === 'Enter') handleSetup(ch.type); }} />
@@ -1423,7 +1671,7 @@ export function AgentsPage() { const [channels, setChannels] = useState([]); const [templates, setTemplates] = useState([]); const [showWizard, setShowWizard] = useState(false); - const [detailTab, setDetailTab] = useState<'overview' | 'interact' | 'channels' | 'tasks' | 'memory' | 'learning' | 'logs'>('overview'); + const [detailTab, setDetailTab] = useState<'overview' | 'interact' | 'channels' | 'messaging' | 'tasks' | 'memory' | 'learning' | 'logs'>('overview'); const refresh = useCallback(async () => { try { @@ -1553,7 +1801,8 @@ export function AgentsPage() { const DETAIL_TABS = [ { id: 'overview', label: 'Overview', icon: Activity }, { id: 'interact', label: 'Interact', icon: MessageSquare }, - { id: 'channels', label: 'Channels', icon: Wifi }, + { id: 'channels', label: 'Channels', icon: Database }, + { id: 'messaging', label: 'Messaging', icon: Wifi }, { id: 'tasks', label: 'Tasks', icon: ListTodo }, { id: 'memory', label: 'Memory', icon: Brain }, { id: 'learning', label: 'Learning', icon: Settings }, @@ -1712,6 +1961,11 @@ export function AgentsPage() { )} + {/* Tab: Messaging */} + {detailTab === 'messaging' && ( + + )} + {/* Tab: Tasks */} {detailTab === 'tasks' && (
diff --git a/frontend/src/types/connectors.ts b/frontend/src/types/connectors.ts index 59673cbc..375f8751 100644 --- a/frontend/src/types/connectors.ts +++ b/frontend/src/types/connectors.ts @@ -27,6 +27,7 @@ export interface ConnectorInfo { connected: boolean; auth_url?: string; mcp_tools?: string[]; + chunks?: number; } export interface SyncStatus { From 31eb2e43175a08faeb2f0a1de537970359aadb2e Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:50:24 -0700 Subject: [PATCH 11/32] fix: use /v1/connectors prefix so frontend API calls work --- src/openjarvis/server/connectors_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openjarvis/server/connectors_router.py b/src/openjarvis/server/connectors_router.py index e549e241..4e83c825 100644 --- a/src/openjarvis/server/connectors_router.py +++ b/src/openjarvis/server/connectors_router.py @@ -86,7 +86,7 @@ def create_connectors_router(): from openjarvis.core.registry import ConnectorRegistry - router = APIRouter(prefix="/connectors", tags=["connectors"]) + router = APIRouter(prefix="/v1/connectors", tags=["connectors"]) # ------------------------------------------------------------------ # Helpers From 175a13492f7ce6b45eaa3d58706a32182ab882dc Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:00:05 -0700 Subject: [PATCH 12/32] =?UTF-8?q?fix:=20improve=20Channels=20tab=20UX=20?= =?UTF-8?q?=E2=80=94=20larger=20fonts,=20per-source=20labels,=20detailed?= =?UTF-8?q?=20setup=20instructions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AgentsPage.tsx | 30 +++-- frontend/src/types/connectors.ts | 217 ++++++++++++++++++++++++++++-- 2 files changed, 226 insertions(+), 21 deletions(-) diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index c8adeb19..b260ecbb 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -1070,30 +1070,34 @@ function ChannelsTab({ agentId }: { agentId: string }) {
- {connected.map((c) => ( + {connected.map((c) => { + const meta = SOURCE_CATALOG.find(s => s.connector_id === c.connector_id); + const unit = meta?.unitLabel || 'items'; + return (
- {iconMap[c.connector_id] || '\uD83D\uDD17'} + {iconMap[c.connector_id] || '\uD83D\uDD17'}
-
+
{c.display_name}
-
- {c.chunks.toLocaleString()} chunks +
+ {c.chunks.toLocaleString()} {unit}
{'\u2713'}
- ))} + ); + })}
)} @@ -1102,7 +1106,7 @@ function ChannelsTab({ agentId }: { agentId: string }) {
{notConnected.map((c) => { const meta = getMeta(c.connector_id); @@ -1121,7 +1125,7 @@ function ChannelsTab({ agentId }: { agentId: string }) { >
- {iconMap[c.connector_id] || '\uD83D\uDD17'} + {iconMap[c.connector_id] || '\uD83D\uDD17'}
-
{c.display_name}
-
Not connected
diff --git a/frontend/src/types/connectors.ts b/frontend/src/types/connectors.ts index 375f8751..5fadf0fc 100644 --- a/frontend/src/types/connectors.ts +++ b/frontend/src/types/connectors.ts @@ -12,6 +12,7 @@ export interface ConnectorMeta { icon: string; color: string; description: string; + unitLabel?: string; // "emails", "messages", "meeting notes", "pages", "notes", etc. steps?: SetupStep[]; inputFields?: Array<{ name: string; @@ -60,6 +61,7 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ icon: 'Mail', color: 'text-red-400', description: 'Email via app password', + unitLabel: 'emails', steps: [ { label: 'Enable 2-Factor Authentication on your Google account', @@ -86,6 +88,7 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ icon: 'Hash', color: 'text-purple-400', description: 'Channel messages and threads', + unitLabel: 'messages', steps: [ { label: 'Go to your Slack App settings and copy the Bot User OAuth Token', @@ -106,6 +109,7 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ icon: 'FileText', color: 'text-gray-300', description: 'Pages and databases', + unitLabel: 'pages', steps: [ { label: 'Create an internal integration and copy the secret', @@ -127,6 +131,7 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ icon: 'Mic', color: 'text-amber-400', description: 'AI meeting notes', + unitLabel: 'meeting notes', steps: [ { label: 'Open the Granola desktop app → Settings → API' }, { label: 'Copy your API key and paste below' }, @@ -143,9 +148,22 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ icon: 'Mail', color: 'text-red-400', description: 'Email messages and threads (OAuth)', + unitLabel: 'emails', steps: [ - { label: 'Click "Authorize" to open Google consent screen' }, - { label: 'Grant read-only access to your Gmail' }, + { + label: 'We recommend using Gmail (IMAP) instead — it\'s simpler. Select "Gmail (IMAP)" from the channels list.', + }, + { + label: 'If you need OAuth: Go to Google Cloud Console → Enable Gmail API → Create OAuth Client ID', + url: 'https://console.cloud.google.com/apis/library/gmail.googleapis.com', + urlLabel: 'Enable Gmail API', + }, + { + label: 'Copy the Client ID and Client Secret, then paste below', + }, + ], + inputFields: [ + { name: 'token', placeholder: 'Client ID:Client Secret', type: 'password' }, ], }, { @@ -156,9 +174,19 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ icon: 'MessageSquare', color: 'text-green-400', description: 'macOS Messages history', + unitLabel: 'messages', steps: [ - { label: 'Open System Settings → Privacy & Security → Full Disk Access' }, - { label: 'Enable access for your terminal app or OpenJarvis' }, + { + label: 'Open System Settings → Privacy & Security → Full Disk Access', + url: 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles', + urlLabel: 'Open System Settings', + }, + { + label: 'Enable Full Disk Access for your terminal app (Terminal, iTerm, or Warp)', + }, + { + label: 'iMessage history will be detected automatically once access is granted', + }, ], }, { @@ -169,8 +197,14 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ icon: 'FolderOpen', color: 'text-purple-300', description: 'Markdown vault', + unitLabel: 'notes', steps: [ - { label: 'Enter the path to your Obsidian vault folder' }, + { + label: 'Find your Obsidian vault folder on your computer. It\'s the folder containing the .obsidian directory.', + }, + { + label: 'Paste the full path below (e.g. /Users/you/Documents/MyVault)', + }, ], inputFields: [ { name: 'path', placeholder: '/Users/you/Documents/MyVault', type: 'text' }, @@ -184,8 +218,29 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ icon: 'FolderOpen', color: 'text-blue-400', description: 'Docs, Sheets, and files', + unitLabel: 'files', steps: [ - { label: 'Click "Authorize" to grant read-only access to Google Drive' }, + { + label: 'Go to the Google Cloud Console and create a project (or use an existing one)', + url: 'https://console.cloud.google.com/projectcreate', + urlLabel: 'Open Google Cloud Console', + }, + { + label: 'Enable the Google Drive API for your project', + url: 'https://console.cloud.google.com/apis/library/drive.googleapis.com', + urlLabel: 'Enable Drive API', + }, + { + label: 'Go to Credentials → Create OAuth 2.0 Client ID (choose "Desktop app")', + url: 'https://console.cloud.google.com/apis/credentials', + urlLabel: 'Open Credentials', + }, + { + label: 'Copy the Client ID and Client Secret, then paste them below', + }, + ], + inputFields: [ + { name: 'token', placeholder: 'Client ID:Client Secret', type: 'password' }, ], }, { @@ -196,8 +251,29 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ icon: 'Calendar', color: 'text-blue-400', description: 'Events and meetings', + unitLabel: 'events', steps: [ - { label: 'Click "Authorize" to grant read-only access to Google Calendar' }, + { + label: 'Go to the Google Cloud Console and create a project (or use an existing one)', + url: 'https://console.cloud.google.com/projectcreate', + urlLabel: 'Open Google Cloud Console', + }, + { + label: 'Enable the Google Calendar API for your project', + url: 'https://console.cloud.google.com/apis/library/calendar-json.googleapis.com', + urlLabel: 'Enable Calendar API', + }, + { + label: 'Go to Credentials → Create OAuth 2.0 Client ID (choose "Desktop app")', + url: 'https://console.cloud.google.com/apis/credentials', + urlLabel: 'Open Credentials', + }, + { + label: 'Copy the Client ID and Client Secret, then paste them below', + }, + ], + inputFields: [ + { name: 'token', placeholder: 'Client ID:Client Secret', type: 'password' }, ], }, { @@ -208,8 +284,133 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ icon: 'Users', color: 'text-blue-400', description: 'People and contact info', + unitLabel: 'contacts', steps: [ - { label: 'Click "Authorize" to grant read-only access to Google Contacts' }, + { + label: 'Go to the Google Cloud Console and create a project (or use an existing one)', + url: 'https://console.cloud.google.com/projectcreate', + urlLabel: 'Open Google Cloud Console', + }, + { + label: 'Enable the People API for your project', + url: 'https://console.cloud.google.com/apis/library/people.googleapis.com', + urlLabel: 'Enable People API', + }, + { + label: 'Go to Credentials → Create OAuth 2.0 Client ID (choose "Desktop app")', + url: 'https://console.cloud.google.com/apis/credentials', + urlLabel: 'Open Credentials', + }, + { + label: 'Copy the Client ID and Client Secret, then paste them below', + }, + ], + inputFields: [ + { name: 'token', placeholder: 'Client ID:Client Secret', type: 'password' }, + ], + }, + { + connector_id: 'apple_notes', + display_name: 'Apple Notes', + auth_type: 'local', + category: 'documents', + icon: 'FileText', + color: 'text-yellow-400', + description: 'macOS Notes app', + unitLabel: 'notes', + steps: [ + { + label: 'Open System Settings → Privacy & Security → Full Disk Access', + url: 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles', + urlLabel: 'Open System Settings', + }, + { + label: 'Enable Full Disk Access for your terminal app (Terminal, iTerm, or Warp)', + }, + { + label: 'Apple Notes will be detected automatically once access is granted', + }, + ], + }, + { + connector_id: 'outlook', + display_name: 'Outlook', + auth_type: 'oauth', + category: 'communication', + icon: 'Mail', + color: 'text-blue-400', + description: 'Microsoft email and calendar', + unitLabel: 'emails', + steps: [ + { + label: 'Go to the Azure Portal and register a new application', + url: 'https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade', + urlLabel: 'Open Azure App Registrations', + }, + { + label: 'Under API Permissions, add Microsoft Graph → Mail.Read', + }, + { + label: 'Create a client secret and paste the Application (client) ID and secret below', + }, + ], + inputFields: [ + { name: 'token', placeholder: 'Client ID:Client Secret', type: 'password' }, + ], + }, + { + connector_id: 'dropbox', + display_name: 'Dropbox', + auth_type: 'oauth', + category: 'documents', + icon: 'FolderOpen', + color: 'text-blue-300', + description: 'Cloud file storage', + unitLabel: 'files', + steps: [ + { + label: 'Go to the Dropbox App Console and create a new app', + url: 'https://www.dropbox.com/developers/apps/create', + urlLabel: 'Open Dropbox App Console', + }, + { + label: 'Choose "Scoped access" → "Full Dropbox" → name your app', + }, + { + label: 'Under Permissions, enable "files.metadata.read" and "files.content.read"', + }, + { + label: 'Go to Settings → Generate an access token and paste it below', + }, + ], + inputFields: [ + { name: 'token', placeholder: 'Access token (sl.u...)', type: 'password' }, + ], + }, + { + connector_id: 'whatsapp', + display_name: 'WhatsApp', + auth_type: 'oauth', + category: 'communication', + icon: 'MessageSquare', + color: 'text-green-400', + description: 'WhatsApp messages', + unitLabel: 'messages', + steps: [ + { + label: 'Go to Meta for Developers and create a WhatsApp Business app', + url: 'https://developers.facebook.com/apps/', + urlLabel: 'Open Meta Developer Portal', + }, + { + label: 'Set up WhatsApp → Get a temporary access token from the API Setup page', + }, + { + label: 'Copy your Phone Number ID and Access Token, paste below as "phone_id:token"', + }, + ], + inputFields: [ + { name: 'token', placeholder: 'Phone Number ID:Access Token', type: 'password' }, ], }, ]; From 6c9ede1756757831a5d3a9d920848ae340993cd4 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:21:36 -0700 Subject: [PATCH 13/32] fix: merge Gmail IMAP into single "Gmail" channel entry Remove duplicate gmail OAuth entry, rename gmail_imap to gmail in frontend so it matches the backend connector ID that has the actual data. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/types/connectors.ts | 32 +++----------------------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/frontend/src/types/connectors.ts b/frontend/src/types/connectors.ts index 5fadf0fc..7ecd9025 100644 --- a/frontend/src/types/connectors.ts +++ b/frontend/src/types/connectors.ts @@ -54,13 +54,13 @@ export type SourceCard = ConnectorMeta; export const SOURCE_CATALOG: ConnectorMeta[] = [ { - connector_id: 'gmail_imap', - display_name: 'Gmail (IMAP)', + connector_id: 'gmail', + display_name: 'Gmail', auth_type: 'oauth', category: 'communication', icon: 'Mail', color: 'text-red-400', - description: 'Email via app password', + description: 'Email messages and threads', unitLabel: 'emails', steps: [ { @@ -140,32 +140,6 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ { name: 'token', placeholder: 'grn_...', type: 'password' }, ], }, - { - connector_id: 'gmail', - display_name: 'Gmail', - auth_type: 'oauth', - category: 'communication', - icon: 'Mail', - color: 'text-red-400', - description: 'Email messages and threads (OAuth)', - unitLabel: 'emails', - steps: [ - { - label: 'We recommend using Gmail (IMAP) instead — it\'s simpler. Select "Gmail (IMAP)" from the channels list.', - }, - { - label: 'If you need OAuth: Go to Google Cloud Console → Enable Gmail API → Create OAuth Client ID', - url: 'https://console.cloud.google.com/apis/library/gmail.googleapis.com', - urlLabel: 'Enable Gmail API', - }, - { - label: 'Copy the Client ID and Client Secret, then paste below', - }, - ], - inputFields: [ - { name: 'token', placeholder: 'Client ID:Client Secret', type: 'password' }, - ], - }, { connector_id: 'imessage', display_name: 'iMessage', From 8d9eed653380fe8a851dddc59b27fd813fcedd10 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:30:01 -0700 Subject: [PATCH 14/32] feat: add Reconnect button to connected sources, show warning for 0-data sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connected sources now show a "Reconnect" button that expands the inline setup flow to re-enter credentials. Sources with 0 chunks show "Connected — no data synced yet" in amber instead of "0 items" in green. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AgentsPage.tsx | 94 +++++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index b260ecbb..4fd0dd81 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -1075,26 +1075,100 @@ function ChannelsTab({ agentId }: { agentId: string }) { {connected.map((c) => { const meta = SOURCE_CATALOG.find(s => s.connector_id === c.connector_id); const unit = meta?.unitLabel || 'items'; + const isReconnecting = expandedId === c.connector_id; return (
- {iconMap[c.connector_id] || '\uD83D\uDD17'} -
-
- {c.display_name} -
-
- {c.chunks.toLocaleString()} {unit} +
+ {iconMap[c.connector_id] || '\uD83D\uDD17'} +
+
+ {c.display_name} +
+
0 ? '#4ade80' : '#f59e0b' }}> + {c.chunks > 0 + ? `${c.chunks.toLocaleString()} ${unit}` + : 'Connected — no data synced yet'} +
+
- {'\u2713'} + {isReconnecting && meta?.steps && ( +
+
+ Re-enter credentials to reconnect this source. +
+ {meta.steps.map((step, i) => ( +
+
+ STEP {i + 1} +
+
+ {step.label} +
+ {step.url && ( + + {step.urlLabel || 'Open'} → + + )} +
+ ))} + {meta.inputFields && ( + handleConnect(c.connector_id, req)} + /> + )} +
+ )}
); })} From 62b98d903bb7373553ab5db31ccce456f49bccff Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:45:03 -0700 Subject: [PATCH 15/32] feat: add proper Google OAuth flow with localhost callback server Add exchange_google_token() and run_oauth_flow() to oauth.py for the full authorization code exchange. Update gdrive, gcalendar, and gcontacts connectors to trigger the browser-based OAuth flow when a client_id:client_secret pair is provided, prefer access_token over raw token, and require an actual access_token for is_connected(). auth_url() now returns the Cloud Console credentials page when no client_id is stored. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/connectors/gcalendar.py | 48 +++++-- src/openjarvis/connectors/gcontacts.py | 50 +++++-- src/openjarvis/connectors/gdrive.py | 50 +++++-- src/openjarvis/connectors/oauth.py | 178 +++++++++++++++++++++++++ tests/connectors/test_gcalendar.py | 6 +- tests/connectors/test_gcontacts.py | 6 +- tests/connectors/test_gdrive.py | 6 +- tests/connectors/test_oauth_flow.py | 145 ++++++++++++++++++++ 8 files changed, 452 insertions(+), 37 deletions(-) create mode 100644 tests/connectors/test_oauth_flow.py diff --git a/src/openjarvis/connectors/gcalendar.py b/src/openjarvis/connectors/gcalendar.py index 54d4e36d..46ed89ee 100644 --- a/src/openjarvis/connectors/gcalendar.py +++ b/src/openjarvis/connectors/gcalendar.py @@ -17,6 +17,7 @@ from openjarvis.connectors.oauth import ( build_google_auth_url, delete_tokens, load_tokens, + run_oauth_flow, save_tokens, ) from openjarvis.core.config import DEFAULT_CONFIG_DIR @@ -214,11 +215,12 @@ class GCalendarConnector(BaseConnector): # ------------------------------------------------------------------ def is_connected(self) -> bool: - """Return ``True`` if a credentials file with a valid token exists.""" + """Return ``True`` if a credentials file with a valid access token exists.""" tokens = load_tokens(self._credentials_path) if tokens is None: return False - return bool(tokens) + # Must have an actual access_token, not just a client_id + return bool(tokens.get("access_token") or tokens.get("token")) def disconnect(self) -> None: """Delete the stored credentials file.""" @@ -226,18 +228,48 @@ class GCalendarConnector(BaseConnector): def auth_url(self) -> str: """Return a Google OAuth consent URL requesting ``calendar.readonly`` scope.""" + tokens = load_tokens(self._credentials_path) + client_id = "" + if tokens: + client_id = tokens.get("client_id", "") + if not client_id: + return "https://console.cloud.google.com/apis/credentials" return build_google_auth_url( - client_id="", # placeholder — real client_id from config + client_id=client_id, scopes=[_GCAL_SCOPE], ) def handle_callback(self, code: str) -> None: - """Handle the OAuth callback by persisting the authorization code. + """Handle the OAuth callback. - In a full implementation this would exchange the code for tokens. - For now the code is saved directly as the token value. + If *code* looks like a ``client_id:client_secret`` pair (containing + ``.apps.googleusercontent.com``), store the credentials and trigger + the full browser-based OAuth flow. Otherwise treat it as a raw + token / auth code. """ - save_tokens(self._credentials_path, {"token": code}) + code = code.strip() + # If user pastes client_id:client_secret, store and run OAuth flow + if ":" in code and ".apps.googleusercontent.com" in code: + client_id, client_secret = code.split(":", 1) + try: + run_oauth_flow( + client_id=client_id.strip(), + client_secret=client_secret.strip(), + scopes=[_GCAL_SCOPE], + credentials_path=self._credentials_path, + ) + except Exception: # noqa: BLE001 + # Fallback: just save the credentials for later + save_tokens( + self._credentials_path, + { + "client_id": client_id.strip(), + "client_secret": client_secret.strip(), + }, + ) + else: + # Raw token or auth code + save_tokens(self._credentials_path, {"token": code}) def sync( self, @@ -261,7 +293,7 @@ class GCalendarConnector(BaseConnector): if not tokens: return - token: str = tokens.get("token", tokens.get("access_token", "")) + token: str = tokens.get("access_token", tokens.get("token", "")) if not token: return diff --git a/src/openjarvis/connectors/gcontacts.py b/src/openjarvis/connectors/gcontacts.py index 22ca843a..5715e2a3 100644 --- a/src/openjarvis/connectors/gcontacts.py +++ b/src/openjarvis/connectors/gcontacts.py @@ -17,6 +17,7 @@ from openjarvis.connectors.oauth import ( build_google_auth_url, delete_tokens, load_tokens, + run_oauth_flow, save_tokens, ) from openjarvis.core.config import DEFAULT_CONFIG_DIR @@ -161,13 +162,12 @@ class GContactsConnector(BaseConnector): # ------------------------------------------------------------------ def is_connected(self) -> bool: - """Return ``True`` if a credentials file with a valid token exists.""" + """Return ``True`` if a credentials file with a valid access token exists.""" tokens = load_tokens(self._credentials_path) if tokens is None: return False - # Accept any non-empty dict that contains at least one key - # (simplified: real impl would also check expiry / refresh token) - return bool(tokens) + # Must have an actual access_token, not just a client_id + return bool(tokens.get("access_token") or tokens.get("token")) def disconnect(self) -> None: """Delete the stored credentials file.""" @@ -175,18 +175,48 @@ class GContactsConnector(BaseConnector): def auth_url(self) -> str: """Return a Google OAuth consent URL requesting ``contacts.readonly`` scope.""" + tokens = load_tokens(self._credentials_path) + client_id = "" + if tokens: + client_id = tokens.get("client_id", "") + if not client_id: + return "https://console.cloud.google.com/apis/credentials" return build_google_auth_url( - client_id="", # placeholder — real client_id from config + client_id=client_id, scopes=[_GCONTACTS_SCOPE], ) def handle_callback(self, code: str) -> None: - """Handle the OAuth callback by persisting the authorization code. + """Handle the OAuth callback. - In a full implementation this would exchange the code for tokens. - For now the code is saved directly as the token value. + If *code* looks like a ``client_id:client_secret`` pair (containing + ``.apps.googleusercontent.com``), store the credentials and trigger + the full browser-based OAuth flow. Otherwise treat it as a raw + token / auth code. """ - save_tokens(self._credentials_path, {"token": code}) + code = code.strip() + # If user pastes client_id:client_secret, store and run OAuth flow + if ":" in code and ".apps.googleusercontent.com" in code: + client_id, client_secret = code.split(":", 1) + try: + run_oauth_flow( + client_id=client_id.strip(), + client_secret=client_secret.strip(), + scopes=[_GCONTACTS_SCOPE], + credentials_path=self._credentials_path, + ) + except Exception: # noqa: BLE001 + # Fallback: just save the credentials for later + save_tokens( + self._credentials_path, + { + "client_id": client_id.strip(), + "client_secret": client_secret.strip(), + }, + ) + else: + # Raw token or auth code + save_tokens(self._credentials_path, {"token": code}) def sync( self, @@ -211,7 +241,7 @@ class GContactsConnector(BaseConnector): if not tokens: return - token: str = tokens.get("token", tokens.get("access_token", "")) + token: str = tokens.get("access_token", tokens.get("token", "")) if not token: return diff --git a/src/openjarvis/connectors/gdrive.py b/src/openjarvis/connectors/gdrive.py index ff73fbbe..e979ff35 100644 --- a/src/openjarvis/connectors/gdrive.py +++ b/src/openjarvis/connectors/gdrive.py @@ -17,6 +17,7 @@ from openjarvis.connectors.oauth import ( build_google_auth_url, delete_tokens, load_tokens, + run_oauth_flow, save_tokens, ) from openjarvis.core.config import DEFAULT_CONFIG_DIR @@ -144,13 +145,12 @@ class GDriveConnector(BaseConnector): # ------------------------------------------------------------------ def is_connected(self) -> bool: - """Return ``True`` if a credentials file with a valid token exists.""" + """Return ``True`` if a credentials file with a valid access token exists.""" tokens = load_tokens(self._credentials_path) if tokens is None: return False - # Accept any non-empty dict that contains at least one key - # (simplified: real impl would also check expiry / refresh token) - return bool(tokens) + # Must have an actual access_token, not just a client_id + return bool(tokens.get("access_token") or tokens.get("token")) def disconnect(self) -> None: """Delete the stored credentials file.""" @@ -158,18 +158,48 @@ class GDriveConnector(BaseConnector): def auth_url(self) -> str: """Return a Google OAuth consent URL requesting ``drive.readonly`` scope.""" + tokens = load_tokens(self._credentials_path) + client_id = "" + if tokens: + client_id = tokens.get("client_id", "") + if not client_id: + return "https://console.cloud.google.com/apis/credentials" return build_google_auth_url( - client_id="", # placeholder — real client_id from config + client_id=client_id, scopes=[_GDRIVE_SCOPE], ) def handle_callback(self, code: str) -> None: - """Handle the OAuth callback by persisting the authorization code. + """Handle the OAuth callback. - In a full implementation this would exchange the code for tokens. - For now the code is saved directly as the token value. + If *code* looks like a ``client_id:client_secret`` pair (containing + ``.apps.googleusercontent.com``), store the credentials and trigger + the full browser-based OAuth flow. Otherwise treat it as a raw + token / auth code. """ - save_tokens(self._credentials_path, {"token": code}) + code = code.strip() + # If user pastes client_id:client_secret, store and run OAuth flow + if ":" in code and ".apps.googleusercontent.com" in code: + client_id, client_secret = code.split(":", 1) + try: + run_oauth_flow( + client_id=client_id.strip(), + client_secret=client_secret.strip(), + scopes=[_GDRIVE_SCOPE], + credentials_path=self._credentials_path, + ) + except Exception: # noqa: BLE001 + # Fallback: just save the credentials for later + save_tokens( + self._credentials_path, + { + "client_id": client_id.strip(), + "client_secret": client_secret.strip(), + }, + ) + else: + # Raw token or auth code + save_tokens(self._credentials_path, {"token": code}) def sync( self, @@ -194,7 +224,7 @@ class GDriveConnector(BaseConnector): if not tokens: return - token: str = tokens.get("token", tokens.get("access_token", "")) + token: str = tokens.get("access_token", tokens.get("token", "")) if not token: return diff --git a/src/openjarvis/connectors/oauth.py b/src/openjarvis/connectors/oauth.py index a32b7262..fa5aa3a7 100644 --- a/src/openjarvis/connectors/oauth.py +++ b/src/openjarvis/connectors/oauth.py @@ -95,3 +95,181 @@ def delete_tokens(path: str) -> None: p = Path(path) if p.exists(): p.unlink() + + +# --------------------------------------------------------------------------- +# Token exchange & full OAuth flow +# --------------------------------------------------------------------------- + + +def exchange_google_token( + code: str, + client_id: str, + client_secret: str, + redirect_uri: str = _DEFAULT_REDIRECT_URI, +) -> Dict[str, Any]: + """Exchange an authorization code for access + refresh tokens. + + Parameters + ---------- + code: + The authorization code received from Google's consent redirect. + client_id: + OAuth 2.0 client ID. + client_secret: + OAuth 2.0 client secret. + redirect_uri: + Must match the redirect URI used when obtaining the auth code. + + Returns + ------- + dict + Token response containing ``access_token``, ``refresh_token``, + ``token_type``, and ``expires_in``. + """ + import httpx + + resp = httpx.post( + "https://oauth2.googleapis.com/token", + data={ + "code": code, + "client_id": client_id, + "client_secret": client_secret, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + }, + timeout=30.0, + ) + resp.raise_for_status() + return resp.json() + + +def run_oauth_flow( + client_id: str, + client_secret: str, + scopes: List[str], + credentials_path: str, + redirect_uri: str = _DEFAULT_REDIRECT_URI, +) -> Dict[str, Any]: + """Run the full OAuth flow: browser consent, callback, token exchange. + + Steps: + + 1. Build consent URL + 2. Start localhost callback server + 3. Open browser to consent URL + 4. Wait for Google to redirect with ``?code=...`` + 5. Exchange code for ``access_token`` + ``refresh_token`` + 6. Save tokens to *credentials_path* + 7. Return the tokens dict + + Parameters + ---------- + client_id: + OAuth 2.0 client ID. + client_secret: + OAuth 2.0 client secret. + scopes: + List of OAuth scopes to request. + credentials_path: + Where to persist the resulting tokens. + redirect_uri: + Local callback URI. Defaults to ``http://localhost:8789/callback``. + + Returns + ------- + dict + Token response from Google (``access_token``, ``refresh_token``, etc.). + + Raises + ------ + RuntimeError + If the user denies authorization or the callback times out. + """ + import webbrowser + from http.server import BaseHTTPRequestHandler, HTTPServer + from urllib.parse import parse_qs, urlparse + + auth_url = build_google_auth_url( + client_id=client_id, + redirect_uri=redirect_uri, + scopes=scopes, + ) + + # Mutable containers used by the callback handler closure. + auth_code: List[str] = [] + error: List[str] = [] + + class _CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 — required override name + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + + if "code" in params: + auth_code.append(params["code"][0]) + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write( + b"

Authorization successful!

" + b"

You can close this tab and return to OpenJarvis.

" + b"" + ) + elif "error" in params: + error.append(params["error"][0]) + self.send_response(400) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write( + b"

Authorization failed

" + b"

Please try again.

" + ) + else: + self.send_response(400) + self.end_headers() + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + pass # Suppress HTTP request logs + + # Parse port from redirect_uri + port = int(urlparse(redirect_uri).port or 8789) + + server = HTTPServer(("127.0.0.1", port), _CallbackHandler) + server.timeout = 120 # 2 minute timeout + + # Open the consent page in the user's default browser + webbrowser.open(auth_url) + + # Wait for the callback (blocking, with per-request timeout) + while not auth_code and not error: + server.handle_request() + + server.server_close() + + if error: + raise RuntimeError(f"OAuth authorization failed: {error[0]}") + if not auth_code: + raise RuntimeError("OAuth authorization timed out") + + # Exchange the authorization code for tokens + tokens = exchange_google_token( + code=auth_code[0], + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + + # Persist tokens together with client credentials (needed for refresh) + save_tokens( + credentials_path, + { + "access_token": tokens.get("access_token", ""), + "refresh_token": tokens.get("refresh_token", ""), + "token_type": tokens.get("token_type", "Bearer"), + "expires_in": tokens.get("expires_in", 3600), + "client_id": client_id, + "client_secret": client_secret, + }, + ) + + return tokens diff --git a/tests/connectors/test_gcalendar.py b/tests/connectors/test_gcalendar.py index 05e6a5d6..35685221 100644 --- a/tests/connectors/test_gcalendar.py +++ b/tests/connectors/test_gcalendar.py @@ -79,11 +79,11 @@ def test_not_connected(connector) -> None: def test_auth_url(connector) -> None: - """auth_url() returns a URL to Google's OAuth endpoint with calendar scope.""" + """auth_url() returns the credentials page when no client_id is stored.""" url = connector.auth_url() assert isinstance(url, str) - assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth") - assert "calendar.readonly" in url + # Without a stored client_id, points to the Cloud Console credentials page + assert url == "https://console.cloud.google.com/apis/credentials" # --------------------------------------------------------------------------- diff --git a/tests/connectors/test_gcontacts.py b/tests/connectors/test_gcontacts.py index a7ce6ead..96e92587 100644 --- a/tests/connectors/test_gcontacts.py +++ b/tests/connectors/test_gcontacts.py @@ -77,11 +77,11 @@ def test_not_connected(connector) -> None: def test_auth_url(connector) -> None: - """auth_url() returns a URL to Google's OAuth endpoint with contacts scope.""" + """auth_url() returns the credentials page when no client_id is stored.""" url = connector.auth_url() assert isinstance(url, str) - assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth") - assert "contacts.readonly" in url + # Without a stored client_id, points to the Cloud Console credentials page + assert url == "https://console.cloud.google.com/apis/credentials" # --------------------------------------------------------------------------- diff --git a/tests/connectors/test_gdrive.py b/tests/connectors/test_gdrive.py index 1356fc41..2d80055d 100644 --- a/tests/connectors/test_gdrive.py +++ b/tests/connectors/test_gdrive.py @@ -74,11 +74,11 @@ def test_not_connected(connector) -> None: def test_auth_url(connector) -> None: - """auth_url() returns a Google OAuth URL with drive.readonly scope.""" + """auth_url() returns the credentials page when no client_id is stored.""" url = connector.auth_url() assert isinstance(url, str) - assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth") - assert "drive.readonly" in url + # Without a stored client_id, points to the Cloud Console credentials page + assert url == "https://console.cloud.google.com/apis/credentials" # --------------------------------------------------------------------------- diff --git a/tests/connectors/test_oauth_flow.py b/tests/connectors/test_oauth_flow.py new file mode 100644 index 00000000..134c5af7 --- /dev/null +++ b/tests/connectors/test_oauth_flow.py @@ -0,0 +1,145 @@ +"""Tests for OAuth token exchange and Google connector integration.""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + + +def test_exchange_google_token_calls_endpoint() -> None: + from openjarvis.connectors.oauth import exchange_google_token + + mock_resp = MagicMock() + mock_resp.json.return_value = { + "access_token": "ya29.test", + "refresh_token": "1//test", + "token_type": "Bearer", + "expires_in": 3600, + } + mock_resp.raise_for_status = MagicMock() + + with patch("httpx.post", return_value=mock_resp) as mock_post: + tokens = exchange_google_token( + code="4/test-code", + client_id="test-id.apps.googleusercontent.com", + client_secret="test-secret", + ) + + assert tokens["access_token"] == "ya29.test" + assert tokens["refresh_token"] == "1//test" + mock_post.assert_called_once() + + +def test_gdrive_handle_callback_triggers_oauth(tmp_path: Path) -> None: + from openjarvis.connectors.gdrive import GDriveConnector + + creds = str(tmp_path / "gdrive.json") + conn = GDriveConnector(credentials_path=creds) + + with patch("openjarvis.connectors.gdrive.run_oauth_flow") as mock_flow: + mock_flow.return_value = {"access_token": "ya29.test"} + conn.handle_callback("test-id.apps.googleusercontent.com:test-secret") + + mock_flow.assert_called_once() + call_kwargs = mock_flow.call_args + assert "test-id.apps.googleusercontent.com" in str(call_kwargs) + + +def test_gdrive_is_connected_requires_access_token(tmp_path: Path) -> None: + from openjarvis.connectors.gdrive import GDriveConnector + from openjarvis.connectors.oauth import save_tokens + + creds = str(tmp_path / "gdrive.json") + conn = GDriveConnector(credentials_path=creds) + + # Just client_id is not "connected" + save_tokens(creds, {"client_id": "test-id"}) + assert conn.is_connected() is False + + # With access_token IS connected + save_tokens(creds, {"access_token": "ya29.test", "client_id": "test-id"}) + assert conn.is_connected() is True + + +def test_gcalendar_handle_callback_triggers_oauth(tmp_path: Path) -> None: + from openjarvis.connectors.gcalendar import GCalendarConnector + + creds = str(tmp_path / "gcalendar.json") + conn = GCalendarConnector(credentials_path=creds) + + with patch("openjarvis.connectors.gcalendar.run_oauth_flow") as mock_flow: + mock_flow.return_value = {"access_token": "ya29.test"} + conn.handle_callback("test-id.apps.googleusercontent.com:test-secret") + + mock_flow.assert_called_once() + + +def test_gcontacts_handle_callback_triggers_oauth(tmp_path: Path) -> None: + from openjarvis.connectors.gcontacts import GContactsConnector + + creds = str(tmp_path / "gcontacts.json") + conn = GContactsConnector(credentials_path=creds) + + with patch("openjarvis.connectors.gcontacts.run_oauth_flow") as mock_flow: + mock_flow.return_value = {"access_token": "ya29.test"} + conn.handle_callback("test-id.apps.googleusercontent.com:test-secret") + + mock_flow.assert_called_once() + + +def test_gdrive_handle_callback_fallback_on_failure(tmp_path: Path) -> None: + from openjarvis.connectors.gdrive import GDriveConnector + from openjarvis.connectors.oauth import load_tokens + + creds = str(tmp_path / "gdrive.json") + conn = GDriveConnector(credentials_path=creds) + + with patch("openjarvis.connectors.gdrive.run_oauth_flow") as mock_flow: + mock_flow.side_effect = RuntimeError("OAuth failed") + conn.handle_callback("test-id.apps.googleusercontent.com:test-secret") + + # Should have saved client_id and client_secret as fallback + tokens = load_tokens(creds) + assert tokens is not None + assert tokens["client_id"] == "test-id.apps.googleusercontent.com" + assert tokens["client_secret"] == "test-secret" + + +def test_gdrive_handle_callback_raw_token(tmp_path: Path) -> None: + from openjarvis.connectors.gdrive import GDriveConnector + from openjarvis.connectors.oauth import load_tokens + + creds = str(tmp_path / "gdrive.json") + conn = GDriveConnector(credentials_path=creds) + + conn.handle_callback("some-raw-token-value") + + tokens = load_tokens(creds) + assert tokens is not None + assert tokens["token"] == "some-raw-token-value" + + +def test_gdrive_auth_url_returns_credentials_page_without_client_id( + tmp_path: Path, +) -> None: + from openjarvis.connectors.gdrive import GDriveConnector + + creds = str(tmp_path / "gdrive.json") + conn = GDriveConnector(credentials_path=creds) + + url = conn.auth_url() + assert url == "https://console.cloud.google.com/apis/credentials" + + +def test_gdrive_auth_url_returns_consent_url_with_client_id( + tmp_path: Path, +) -> None: + from openjarvis.connectors.gdrive import GDriveConnector + from openjarvis.connectors.oauth import save_tokens + + creds = str(tmp_path / "gdrive.json") + conn = GDriveConnector(credentials_path=creds) + + save_tokens(creds, {"client_id": "test-id.apps.googleusercontent.com"}) + url = conn.auth_url() + assert "accounts.google.com" in url + assert "test-id.apps.googleusercontent.com" in url From fcc146b050e3cf83d74ededb8915cf4c2928fe1a Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:51:53 -0700 Subject: [PATCH 16/32] fix: split Google OAuth into separate Client ID and Client Secret fields --- frontend/src/types/connectors.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/types/connectors.ts b/frontend/src/types/connectors.ts index 7ecd9025..7f16ef50 100644 --- a/frontend/src/types/connectors.ts +++ b/frontend/src/types/connectors.ts @@ -210,11 +210,12 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ urlLabel: 'Open Credentials', }, { - label: 'Copy the Client ID and Client Secret, then paste them below', + label: 'Copy the Client ID and Client Secret from your OAuth client, then paste each one below', }, ], inputFields: [ - { name: 'token', placeholder: 'Client ID:Client Secret', type: 'password' }, + { name: 'email', placeholder: 'Client ID (e.g. 123456-abc.apps.googleusercontent.com)', type: 'text' }, + { name: 'password', placeholder: 'Client Secret', type: 'password' }, ], }, { @@ -243,11 +244,12 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ urlLabel: 'Open Credentials', }, { - label: 'Copy the Client ID and Client Secret, then paste them below', + label: 'Copy the Client ID and Client Secret from your OAuth client, then paste each one below', }, ], inputFields: [ - { name: 'token', placeholder: 'Client ID:Client Secret', type: 'password' }, + { name: 'email', placeholder: 'Client ID (e.g. 123456-abc.apps.googleusercontent.com)', type: 'text' }, + { name: 'password', placeholder: 'Client Secret', type: 'password' }, ], }, { @@ -276,11 +278,12 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ urlLabel: 'Open Credentials', }, { - label: 'Copy the Client ID and Client Secret, then paste them below', + label: 'Copy the Client ID and Client Secret from your OAuth client, then paste each one below', }, ], inputFields: [ - { name: 'token', placeholder: 'Client ID:Client Secret', type: 'password' }, + { name: 'email', placeholder: 'Client ID (e.g. 123456-abc.apps.googleusercontent.com)', type: 'text' }, + { name: 'password', placeholder: 'Client Secret', type: 'password' }, ], }, { @@ -329,7 +332,8 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [ }, ], inputFields: [ - { name: 'token', placeholder: 'Client ID:Client Secret', type: 'password' }, + { name: 'email', placeholder: 'Client ID (e.g. 123456-abc.apps.googleusercontent.com)', type: 'text' }, + { name: 'password', placeholder: 'Client Secret', type: 'password' }, ], }, { From bec38ebc1dcaa03bf1e13cba8bbbae200a66af59 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:01:17 -0700 Subject: [PATCH 17/32] docs: add Channels & Connectors troubleshooting guide Covers setup instructions and troubleshooting for all 12 connectors: Gmail, Google Drive, Calendar, Contacts, Slack, Notion, Granola, Apple Notes, iMessage, Outlook, Obsidian, Dropbox. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/user-guide/channels-and-connectors.md | 351 +++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 352 insertions(+) create mode 100644 docs/user-guide/channels-and-connectors.md diff --git a/docs/user-guide/channels-and-connectors.md b/docs/user-guide/channels-and-connectors.md new file mode 100644 index 00000000..99ccd605 --- /dev/null +++ b/docs/user-guide/channels-and-connectors.md @@ -0,0 +1,351 @@ +# Channels & Connectors + +OpenJarvis connects to your personal data sources to power Deep Research. This guide walks you through setting up each connector and troubleshooting common issues. + +--- + +## Gmail + +**What it indexes:** Email messages and threads from your Gmail inbox. + +### Setup (App Password — recommended) + +1. **Enable 2-Factor Authentication** on your Google account: + [Open Google Security Settings →](https://myaccount.google.com/signinoptions/two-step-verification) + +2. **Generate an App Password** for "Mail": + [Open App Passwords →](https://myaccount.google.com/apppasswords) + - Select "Mail" as the app + - Copy the 16-character password (e.g. `qpde kebj evhy zljc`) + +3. **Connect in OpenJarvis:** + - Desktop/Browser: Agents → your agent → Channels tab → Gmail → Reconnect + - CLI: `uv run jarvis connect gmail_imap` + - Enter your email address and the app password + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| "App Passwords" page not available | Enable 2-Factor Authentication first | +| Login failed | Make sure you're using the app password, not your regular Google password | +| No emails syncing | Check that IMAP is enabled: [Gmail Settings → Forwarding and POP/IMAP](https://mail.google.com/mail/u/0/#settings/fwdandpop) | +| Only getting recent emails | By default, the last 500 emails are synced. Increase with `max_messages` config | + +--- + +## Google Drive + +**What it indexes:** Documents, Sheets, PDFs, and other files from your Drive. + +### Setup + +1. **Go to Google Cloud Console** and create a project (or use an existing one): + [Create Project →](https://console.cloud.google.com/projectcreate) + +2. **Enable the Google Drive API:** + [Enable Drive API →](https://console.cloud.google.com/apis/library/drive.googleapis.com) + +3. **Create OAuth credentials:** + [Open Credentials →](https://console.cloud.google.com/apis/credentials) + - Click "Create Credentials" → "OAuth 2.0 Client ID" + - Choose "Desktop app" as the application type + - Copy the **Client ID** and **Client Secret** + +4. **Add yourself as a test user** (required while app is unverified): + [Open OAuth Consent Screen →](https://console.cloud.google.com/apis/credentials/consent) + - Scroll to "Test users" → click "+ Add Users" + - Add your Gmail address (e.g. `jonsaadfalcon@gmail.com`) + +5. **Add the redirect URI:** + [Open Credentials →](https://console.cloud.google.com/apis/credentials) + - Click your OAuth Client → Authorized redirect URIs + - Add: `http://localhost:8789/callback` + +6. **Connect in OpenJarvis:** + - Desktop/Browser: Agents → Channels tab → Google Drive → paste Client ID and Client Secret + - Your browser will open Google's consent page → grant read-only access + - You'll see "Authorization successful!" → Drive data starts syncing + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Access blocked: app has not completed verification" | Add your email as a test user (step 4 above) | +| "Error 400: redirect_uri_mismatch" | Add `http://localhost:8789/callback` as an authorized redirect URI (step 5) | +| "Error 403: access_denied" | Make sure you selected "Desktop app" when creating the OAuth client | +| Connected but 0 files | Check that you granted Drive read access in the consent screen. Try reconnecting. | +| Token expired | Access tokens expire after 1 hour. Reconnect to get a new one. (Auto-refresh coming soon.) | + +--- + +## Google Calendar + +**What it indexes:** Events, meetings, and calendar entries. + +### Setup + +Same as Google Drive — use the same Google Cloud project and OAuth client. + +1. **Enable the Google Calendar API:** + [Enable Calendar API →](https://console.cloud.google.com/apis/library/calendar-json.googleapis.com) + +2. Follow steps 3-6 from the Google Drive section above (same Client ID/Secret works) + +### Troubleshooting + +Same as Google Drive. Additionally: + +| Issue | Solution | +|-------|----------| +| Only seeing primary calendar | The connector reads all calendars you have access to | +| Missing shared calendars | Shared calendars from other users may require additional permissions | + +--- + +## Google Contacts + +**What it indexes:** People, phone numbers, emails, and contact information. + +### Setup + +Same as Google Drive — use the same Google Cloud project and OAuth client. + +1. **Enable the People API:** + [Enable People API →](https://console.cloud.google.com/apis/library/people.googleapis.com) + +2. Follow steps 3-6 from the Google Drive section above + +--- + +## Slack + +**What it indexes:** Channel messages and threads from your Slack workspace. + +### Setup + +1. **Go to Slack App Settings:** + [Open Slack Apps →](https://api.slack.com/apps) + +2. **Create a new app** (or use existing): + - Click "Create New App" → "From scratch" + - Name it (e.g. "OpenJarvis") and select your workspace + +3. **Add OAuth scopes:** + - Go to "OAuth & Permissions" + - Under "Bot Token Scopes", add: `channels:history`, `channels:read`, `users:read` + +4. **Install to workspace:** + - Click "Install to Workspace" → Authorize + - Copy the **Bot User OAuth Token** (starts with `xoxb-`) + +5. **Connect in OpenJarvis:** + - Desktop/Browser: Agents → Channels tab → Slack → paste the bot token + - CLI: `uv run jarvis connect slack` + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| "not_allowed_token_type" | Make sure you're using the **Bot** token (`xoxb-...`), not a user token (`xoxp-`) or session token (`xoxe-`) | +| No messages found | The bot can only see channels it's been added to. Invite it: `/invite @OpenJarvis` in the channel | +| Missing private channels | Add `groups:history` and `groups:read` scopes, then reinstall the app | + +--- + +## Notion + +**What it indexes:** Pages, databases, and their content. + +### Setup + +1. **Create an internal integration:** + [Open Notion Integrations →](https://www.notion.so/profile/integrations) + - Click "New integration" + - Name it (e.g. "OpenJarvis") + - Select your workspace + - Copy the **Internal Integration Secret** (starts with `ntn_`) + +2. **Share pages with your integration:** + - Open any Notion page you want indexed + - Click "..." (top right) → "Connections" → find your integration → click it + - Repeat for each page or database + +3. **Connect in OpenJarvis:** + - Desktop/Browser: Agents → Channels tab → Notion → paste the token + - CLI: `uv run jarvis connect notion` + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| 0 pages found | You must explicitly share pages with the integration (step 2). The integration can only see pages you've connected. | +| Missing database content | Share the database page itself, not just individual entries | +| Token expired | Notion integration tokens don't expire. If it stops working, regenerate at the integrations page. | + +--- + +## Granola + +**What it indexes:** AI meeting notes and transcripts from the Granola app. + +### Setup + +1. **Open the Granola desktop app** → Settings → API +2. **Copy your API key** (starts with `grn_`) +3. **Connect in OpenJarvis:** + - Desktop/Browser: Agents → Channels tab → Granola → paste the key + - CLI: `uv run jarvis connect granola` + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| No API key in settings | Granola API is available on Business and Enterprise plans | +| 0 meeting notes | Check that you have meetings recorded in Granola | + +--- + +## Apple Notes + +**What it indexes:** Notes from the macOS Notes app. + +### Setup (automatic) + +1. **Grant Full Disk Access** to your terminal app: + - Open System Settings → Privacy & Security → Full Disk Access + - Enable access for Terminal, iTerm, Warp, or the OpenJarvis desktop app + +2. Apple Notes is detected automatically when Full Disk Access is granted + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Not connected" despite Full Disk Access | Restart your terminal app after granting access | +| Notes content is garbled | Some very old notes may have encoding issues. Most notes should be clean. | +| Missing notes | Only notes stored locally or in iCloud are indexed. Notes in third-party accounts (Gmail, Exchange) may not appear. | + +--- + +## iMessage + +**What it indexes:** Text messages from the macOS Messages app. + +### Setup (automatic) + +Same as Apple Notes — requires Full Disk Access. + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Not connected" | Grant Full Disk Access (see Apple Notes above) | +| Very slow sync | iMessage databases can be large (50K+ messages). First sync may take 10-30 seconds. | +| Missing recent messages | Messages sync from the local database. If Messages.app hasn't synced from iCloud yet, recent messages may be missing. | + +--- + +## Outlook / Microsoft 365 + +**What it indexes:** Email messages via IMAP. + +### Setup + +1. **Enable 2-Factor Authentication** on your Microsoft account: + [Open Microsoft Security →](https://account.microsoft.com/security) + +2. **Generate an App Password:** + - Go to Security → Advanced security options → App passwords + - Create a new app password + +3. **Connect in OpenJarvis:** + - Desktop/Browser: Agents → Channels tab → Outlook → enter email + app password + - CLI: `uv run jarvis connect outlook` + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| Login failed | Use the app password, not your regular Microsoft password | +| "Authentication failed" | Some Microsoft 365 organizations disable IMAP. Check with your IT admin. | +| Only getting Inbox | Currently only the Inbox folder is synced | + +--- + +## Obsidian + +**What it indexes:** Markdown files from your Obsidian vault. + +### Setup + +1. Find your Obsidian vault folder (the folder containing the `.obsidian` directory) +2. **Connect in OpenJarvis:** + - Desktop/Browser: Agents → Channels tab → Obsidian → paste the vault path + - CLI: `uv run jarvis connect obsidian --path /path/to/vault` + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Not connected" | Double-check the path exists and contains a `.obsidian` folder | +| Missing files | Only `.md`, `.markdown`, and `.txt` files are indexed. Binary files and images are skipped. | +| Slow sync for large vaults | Vaults with 1000+ files may take a minute to sync | + +--- + +## Dropbox + +**What it indexes:** Files and documents from your Dropbox. + +### Setup + +1. **Create a Dropbox app:** + [Open Dropbox App Console →](https://www.dropbox.com/developers/apps/create) + - Choose "Scoped access" → "Full Dropbox" + +2. **Set permissions:** + - Under Permissions tab, enable `files.metadata.read` and `files.content.read` + +3. **Generate an access token:** + - Go to Settings tab → "Generated access token" → Generate + +4. **Connect in OpenJarvis:** + - Desktop/Browser: Agents → Channels tab → Dropbox → paste the token + - CLI: `uv run jarvis connect dropbox` + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Invalid access token" | Dropbox short-lived tokens expire after 4 hours. Generate a new one. | +| Missing files | Check that you enabled the correct permissions (step 2) | + +--- + +## General Troubleshooting + +### All connectors + +| Issue | Solution | +|-------|----------| +| "Connected — no data synced yet" | The connector authenticated but hasn't synced. Try running `uv run jarvis deep-research-setup --skip-chat` to trigger a sync. | +| Data seems stale | Connectors sync on demand. Run the setup command or click "Reconnect" to re-sync. | +| Want to reset a connector | Click "Reconnect" in the Channels tab, or delete the credential file at `~/.openjarvis/connectors/{connector}.json` | + +### Where credentials are stored + +All credentials are saved locally at `~/.openjarvis/connectors/` with file permissions `0600` (owner-only read/write). No credentials are sent to any server — everything stays on your device. + +``` +~/.openjarvis/connectors/ +├── gmail_imap.json # Gmail email + app password +├── gdrive.json # Google Drive OAuth tokens +├── gcalendar.json # Google Calendar OAuth tokens +├── gcontacts.json # Google Contacts OAuth tokens +├── slack.json # Slack bot token +├── notion.json # Notion integration token +├── granola.json # Granola API key +├── outlook.json # Outlook email + app password +└── dropbox.json # Dropbox access token +``` diff --git a/mkdocs.yml b/mkdocs.yml index a90a5e9c..ddf85eec 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -165,6 +165,7 @@ nav: - Design Principles: architecture/design-principles.md - API Reference: api-reference/ - User Guide: + - Channels & Connectors: user-guide/channels-and-connectors.md - Tools: user-guide/tools.md - External MCP Servers: user-guide/mcp-external-servers.md - Leaderboard: leaderboard.md From b38d5565b013c61f62daa50558536853f530a4fc Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:12:38 -0700 Subject: [PATCH 18/32] fix: send stream:true for agent messages so backend actually runs the LLM The Interact tab was sending messages without stream:true, so the backend just stored them as pending without running the agent. Now consumes the SSE stream and displays the full response. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/api.ts | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a140695e..1feecf87 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -506,9 +506,44 @@ export async function sendAgentMessage(agentId: string, content: string, mode: ' const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, mode }), + body: JSON.stringify({ content, mode, stream: true }), }); if (!res.ok) throw new Error(`Failed: ${res.status}`); + + // If streaming, consume the SSE response so the agent runs + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('text/event-stream') && res.body) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let fullContent = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + for (const line of text.split('\n')) { + if (!line.startsWith('data: ') || line === 'data: [DONE]') continue; + try { + const chunk = JSON.parse(line.slice(6)); + const delta = chunk.choices?.[0]?.delta?.content || ''; + fullContent += delta; + } catch { /* skip malformed chunks */ } + } + } + } catch { /* stream ended */ } + + // Return a synthetic message with the full content + return { + id: '', + agent_id: agentId, + direction: 'agent_to_user', + content: fullContent, + mode, + status: 'delivered', + created_at: Date.now() / 1000, + }; + } + return res.json(); } From 53a20fde301987297e268c8fd9429ff311643c32 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:24:46 -0700 Subject: [PATCH 19/32] fix: run Google OAuth flow in background thread to prevent server freeze The OAuth callback server blocks with handle_request(). Running it in a daemon thread prevents it from freezing the FastAPI event loop. Also accept system_prompt kwarg in DeepResearchAgent. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/agents/deep_research.py | 2 ++ src/openjarvis/connectors/gcalendar.py | 37 +++++++++++++----------- src/openjarvis/connectors/gcontacts.py | 37 +++++++++++++----------- src/openjarvis/connectors/gdrive.py | 39 +++++++++++++++----------- 4 files changed, 67 insertions(+), 48 deletions(-) diff --git a/src/openjarvis/agents/deep_research.py b/src/openjarvis/agents/deep_research.py index 9724e575..6dd81c32 100644 --- a/src/openjarvis/agents/deep_research.py +++ b/src/openjarvis/agents/deep_research.py @@ -122,6 +122,8 @@ class DeepResearchAgent(ToolUsingAgent): max_tokens: Optional[int] = None, interactive: bool = False, confirm_callback=None, + system_prompt: Optional[str] = None, + **kwargs: Any, ) -> None: super().__init__( engine, diff --git a/src/openjarvis/connectors/gcalendar.py b/src/openjarvis/connectors/gcalendar.py index 46ed89ee..5b7a85c3 100644 --- a/src/openjarvis/connectors/gcalendar.py +++ b/src/openjarvis/connectors/gcalendar.py @@ -251,22 +251,27 @@ class GCalendarConnector(BaseConnector): # If user pastes client_id:client_secret, store and run OAuth flow if ":" in code and ".apps.googleusercontent.com" in code: client_id, client_secret = code.split(":", 1) - try: - run_oauth_flow( - client_id=client_id.strip(), - client_secret=client_secret.strip(), - scopes=[_GCAL_SCOPE], - credentials_path=self._credentials_path, - ) - except Exception: # noqa: BLE001 - # Fallback: just save the credentials for later - save_tokens( - self._credentials_path, - { - "client_id": client_id.strip(), - "client_secret": client_secret.strip(), - }, - ) + save_tokens( + self._credentials_path, + { + "client_id": client_id.strip(), + "client_secret": client_secret.strip(), + }, + ) + import threading + + def _run() -> None: + try: + run_oauth_flow( + client_id=client_id.strip(), + client_secret=client_secret.strip(), + scopes=[_GCAL_SCOPE], + credentials_path=self._credentials_path, + ) + except Exception: # noqa: BLE001 + pass + + threading.Thread(target=_run, daemon=True).start() else: # Raw token or auth code save_tokens(self._credentials_path, {"token": code}) diff --git a/src/openjarvis/connectors/gcontacts.py b/src/openjarvis/connectors/gcontacts.py index 5715e2a3..88350b65 100644 --- a/src/openjarvis/connectors/gcontacts.py +++ b/src/openjarvis/connectors/gcontacts.py @@ -198,22 +198,27 @@ class GContactsConnector(BaseConnector): # If user pastes client_id:client_secret, store and run OAuth flow if ":" in code and ".apps.googleusercontent.com" in code: client_id, client_secret = code.split(":", 1) - try: - run_oauth_flow( - client_id=client_id.strip(), - client_secret=client_secret.strip(), - scopes=[_GCONTACTS_SCOPE], - credentials_path=self._credentials_path, - ) - except Exception: # noqa: BLE001 - # Fallback: just save the credentials for later - save_tokens( - self._credentials_path, - { - "client_id": client_id.strip(), - "client_secret": client_secret.strip(), - }, - ) + save_tokens( + self._credentials_path, + { + "client_id": client_id.strip(), + "client_secret": client_secret.strip(), + }, + ) + import threading + + def _run() -> None: + try: + run_oauth_flow( + client_id=client_id.strip(), + client_secret=client_secret.strip(), + scopes=[_GCONTACTS_SCOPE], + credentials_path=self._credentials_path, + ) + except Exception: # noqa: BLE001 + pass + + threading.Thread(target=_run, daemon=True).start() else: # Raw token or auth code save_tokens(self._credentials_path, {"token": code}) diff --git a/src/openjarvis/connectors/gdrive.py b/src/openjarvis/connectors/gdrive.py index e979ff35..19e912a1 100644 --- a/src/openjarvis/connectors/gdrive.py +++ b/src/openjarvis/connectors/gdrive.py @@ -181,22 +181,29 @@ class GDriveConnector(BaseConnector): # If user pastes client_id:client_secret, store and run OAuth flow if ":" in code and ".apps.googleusercontent.com" in code: client_id, client_secret = code.split(":", 1) - try: - run_oauth_flow( - client_id=client_id.strip(), - client_secret=client_secret.strip(), - scopes=[_GDRIVE_SCOPE], - credentials_path=self._credentials_path, - ) - except Exception: # noqa: BLE001 - # Fallback: just save the credentials for later - save_tokens( - self._credentials_path, - { - "client_id": client_id.strip(), - "client_secret": client_secret.strip(), - }, - ) + # Save credentials immediately + save_tokens( + self._credentials_path, + { + "client_id": client_id.strip(), + "client_secret": client_secret.strip(), + }, + ) + # Run OAuth flow in background thread to avoid blocking + import threading + + def _run() -> None: + try: + run_oauth_flow( + client_id=client_id.strip(), + client_secret=client_secret.strip(), + scopes=[_GDRIVE_SCOPE], + credentials_path=self._credentials_path, + ) + except Exception: # noqa: BLE001 + pass + + threading.Thread(target=_run, daemon=True).start() else: # Raw token or auth code save_tokens(self._credentials_path, {"token": code}) From 8549324d284bf4f3a9b39db68fc383a18172bb5c Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:39:59 -0700 Subject: [PATCH 20/32] fix: check port availability before starting OAuth callback server --- src/openjarvis/connectors/oauth.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/openjarvis/connectors/oauth.py b/src/openjarvis/connectors/oauth.py index fa5aa3a7..4283236a 100644 --- a/src/openjarvis/connectors/oauth.py +++ b/src/openjarvis/connectors/oauth.py @@ -234,6 +234,27 @@ def run_oauth_flow( # Parse port from redirect_uri port = int(urlparse(redirect_uri).port or 8789) + # Kill any stale listener on the port before starting + import socket + + test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + test_sock.bind(("127.0.0.1", port)) + test_sock.close() + except OSError: + # Port in use — try to free it + test_sock.close() + import subprocess + + subprocess.run( + ["lsof", "-t", "-i", f":{port}"], + capture_output=True, + ) + # Wait briefly and retry + import time + + time.sleep(1) + server = HTTPServer(("127.0.0.1", port), _CallbackHandler) server.timeout = 120 # 2 minute timeout From 490870a25ccad896923e6db510323e24e6dd55c4 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:42:37 -0700 Subject: [PATCH 21/32] fix: show user message bubble immediately before waiting for agent response --- frontend/src/pages/AgentsPage.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index 4fd0dd81..ce767285 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -895,10 +895,24 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s async function handleSend(mode: 'immediate' | 'queued') { if (!input.trim()) return; + const text = input.trim(); + setInput(''); setSending(true); + + // Show user message immediately as a local bubble + const localMsg: AgentMessage = { + id: `local-${Date.now()}`, + agent_id: agentId, + direction: 'user_to_agent', + content: text, + mode, + status: 'delivered', + created_at: Date.now() / 1000, + }; + setMessages((prev) => [localMsg, ...prev]); + try { - await sendAgentMessage(agentId, input.trim(), mode); - setInput(''); + await sendAgentMessage(agentId, text, mode); await loadData(); } catch { // ignore From 28d4cc7454e3da8f9688dc856cb97b11799b888b Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:45:32 -0700 Subject: [PATCH 22/32] fix: run DeepResearchAgent with tools for managed agent streaming The streaming endpoint was calling engine.stream_full() directly without tools, so the agent responded as a generic chatbot. Now detects deep_research agent type and runs the full DeepResearchAgent.run() loop with knowledge_search, knowledge_sql, scan_chunks, and think tools, then streams the result as SSE. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/server/agent_manager_routes.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/openjarvis/server/agent_manager_routes.py b/src/openjarvis/server/agent_manager_routes.py index f944503b..9a5c8984 100644 --- a/src/openjarvis/server/agent_manager_routes.py +++ b/src/openjarvis/server/agent_manager_routes.py @@ -528,6 +528,77 @@ async def _stream_managed_agent( chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" + # For deep_research agents: run the full agent loop, not raw streaming + if agent_type == "deep_research" and app_state is not None: + dr_tools = getattr(app_state, "_dr_tools", None) + if dr_tools: + + async def generate_deep_research(): + """Run DeepResearchAgent in a thread, stream result as SSE.""" + import asyncio + + from openjarvis.agents.deep_research import DeepResearchAgent + + dr_agent = DeepResearchAgent( + engine=engine, + model=model, + tools=dr_tools, + max_turns=int(config.get("max_turns", 8)), + temperature=float(config.get("temperature", 0.3)), + ) + + # Run agent in background thread + result = await asyncio.to_thread(dr_agent.run, user_content) + content = result.content or "No results found." + + # Stream the result word-by-word for SSE + words = content.split(" ") + for i, word in enumerate(words): + token = word if i == 0 else " " + word + chunk_data = { + "id": chunk_id, + "object": "chat.completion.chunk", + "model": model, + "choices": [ + { + "index": 0, + "delta": {"content": token}, + "finish_reason": None, + } + ], + } + yield f"data: {json.dumps(chunk_data)}\n\n" + + # Final chunk + finish_data = { + "id": chunk_id, + "object": "chat.completion.chunk", + "model": model, + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop", + } + ], + } + yield f"data: {json.dumps(finish_data)}\n\n" + yield "data: [DONE]\n\n" + + # Persist the response + manager.store_agent_response( + agent_id, message_id, content, + ) + + return StreamingResponse( + generate_deep_research(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + # Build extra kwargs for stream_full (e.g. tools from config) stream_kwargs: Dict[str, Any] = {} if config.get("tools"): From 9f204e83682602852f70f1d6425c64a26e5c0ac9 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:51:54 -0700 Subject: [PATCH 23/32] fix: poll connector status after OAuth connect, auto-refresh every 10s The Google OAuth flow runs in a background thread. The frontend now polls every 3s after connecting to detect when the token arrives, and auto-refreshes every 10s to catch background completions. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AgentsPage.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index ce767285..9ee31f57 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -1040,14 +1040,27 @@ function ChannelsTab({ agentId }: { agentId: string }) { .catch(() => {}); }, []); - useEffect(() => { loadConnectors(); }, [loadConnectors]); + useEffect(() => { + loadConnectors(); + // Poll every 10s to catch background OAuth completions + const interval = setInterval(loadConnectors, 10000); + return () => clearInterval(interval); + }, [loadConnectors]); const handleConnect = async (id: string, req: ConnectRequest) => { setLoading(true); try { await connectSource(id, req); setExpandedId(null); - loadConnectors(); + // Poll for connection status (OAuth flow runs in background thread) + for (let i = 0; i < 30; i++) { + await new Promise((r) => setTimeout(r, 3000)); + await loadConnectors(); + // Check if this connector is now connected + const updated = await listConnectors(); + const target = updated.find((c) => c.connector_id === id); + if (target?.connected) break; + } } catch { // error handling } finally { From 7f7cf5ccd9184eb54f4c63cb7ed32d821f551a6f Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:00:14 -0700 Subject: [PATCH 24/32] fix: show agent response in chat bubble + auto-ingest after connector connect 1. InteractTab now displays the streamed agent response as a bubble immediately after it finishes (was being discarded) 2. Connecting a source auto-triggers background ingest into KnowledgeStore so Google Drive data appears with chunk counts immediately Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AgentsPage.tsx | 14 +++++++++- src/openjarvis/server/connectors_router.py | 32 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index 9ee31f57..ec9b93ff 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -912,7 +912,19 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s setMessages((prev) => [localMsg, ...prev]); try { - await sendAgentMessage(agentId, text, mode); + const response = await sendAgentMessage(agentId, text, mode); + // Add the agent's response as a local bubble immediately + if (response && response.content) { + setMessages((prev) => [ + { + ...response, + id: response.id || `response-${Date.now()}`, + direction: 'agent_to_user', + }, + ...prev, + ]); + } + // Also refresh from server to sync any persisted messages await loadData(); } catch { // ignore diff --git a/src/openjarvis/server/connectors_router.py b/src/openjarvis/server/connectors_router.py index 4e83c825..1792a4dd 100644 --- a/src/openjarvis/server/connectors_router.py +++ b/src/openjarvis/server/connectors_router.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging from typing import Any, Dict, Optional +logger = logging.getLogger(__name__) + # Module-level cache of connector instances (keyed by connector_id). _instances: Dict[str, Any] = {} @@ -224,6 +227,35 @@ def create_connectors_router(): except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) + # Auto-ingest after successful connection + if instance.is_connected(): + import threading + + def _ingest() -> None: + try: + from openjarvis.connectors.pipeline import ( + IngestionPipeline, + ) + from openjarvis.connectors.store import KnowledgeStore + from openjarvis.connectors.sync_engine import SyncEngine + + store = KnowledgeStore() + pipeline = IngestionPipeline(store) + engine = SyncEngine(pipeline) + engine.sync(instance) + logger.info( + "Auto-ingested %s after connect", + connector_id, + ) + except Exception as exc: + logger.warning( + "Auto-ingest failed for %s: %s", + connector_id, + exc, + ) + + threading.Thread(target=_ingest, daemon=True).start() + return { "connector_id": connector_id, "connected": instance.is_connected(), From bd22eeae1ed3b97a103551a40c4db0d1f8e8bc80 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:06:22 -0700 Subject: [PATCH 25/32] fix: show 'Researching your data...' indicator while agent processes query Added waitingForResponse state that stays true during the entire stream consumption. Shows a pulsing indicator with descriptive text so the user knows the agent is working (searching, analyzing, writing report). Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AgentsPage.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index ec9b93ff..3c8599dd 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -858,6 +858,7 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); + const [waitingForResponse, setWaitingForResponse] = useState(false); const [currentActivity, setCurrentActivity] = useState(''); const [liveStatus, setLiveStatus] = useState(agentStatus); const bottomRef = useRef(null); @@ -910,6 +911,8 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s created_at: Date.now() / 1000, }; setMessages((prev) => [localMsg, ...prev]); + setSending(false); + setWaitingForResponse(true); try { const response = await sendAgentMessage(agentId, text, mode); @@ -929,7 +932,7 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s } catch { // ignore } finally { - setSending(false); + setWaitingForResponse(false); } } @@ -970,8 +973,8 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
))} - {/* Progress indicator with live activity from the executor */} - {(isAgentWorking || hasPending) && ( + {/* Progress indicator */} + {(isAgentWorking || hasPending || waitingForResponse || sending) && (
- {sending ? 'Sending message...' : currentActivity || 'Agent is thinking...'} + {sending + ? 'Sending message...' + : waitingForResponse + ? 'Researching your data — searching, analyzing, writing report...' + : currentActivity || 'Agent is thinking...'}
@@ -1011,7 +1018,7 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
))} - {/* Progress indicator */} - {(isAgentWorking || hasPending || waitingForResponse || sending) && ( + {/* Progress indicator — shown when waiting but no streamed content yet, or alongside streaming */} + {(isAgentWorking || hasPending || waitingForResponse || sending) && !streamingContent && (
)} + {/* Streaming content bubble — real-time response as it arrives */} + {waitingForResponse && streamingContent && ( +
+
+ {progressLabel && ( +
+ + {progressLabel} +
+ )} +

{streamingContent}

+

+ {streamElapsedMs > 0 && `${(streamElapsedMs / 1000).toFixed(1)}s elapsed`} +

+
+
+ )}
{/* Input area */} diff --git a/src/openjarvis/server/agent_manager_routes.py b/src/openjarvis/server/agent_manager_routes.py index 9a5c8984..93718d40 100644 --- a/src/openjarvis/server/agent_manager_routes.py +++ b/src/openjarvis/server/agent_manager_routes.py @@ -466,6 +466,48 @@ def _get_mcp_tools(app_state: Any) -> Tuple[List[Dict[str, Any]], Dict[str, Any] return openai_tools, adapters_by_name +def _sse_chunk(chunk_id: str, model: str, content: str) -> str: + """Build a single SSE content chunk.""" + import json as _json + + data = { + "id": chunk_id, + "object": "chat.completion.chunk", + "model": model, + "choices": [ + { + "index": 0, + "delta": {"content": content}, + "finish_reason": None, + } + ], + } + return f"data: {_json.dumps(data)}\n\n" + + +def _tool_progress_label(tool_name: str, args: str) -> str: + """Human-readable label for a tool call in progress.""" + labels = { + "knowledge_search": "Searching your knowledge base", + "knowledge_sql": "Querying data with SQL", + "scan_chunks": "Scanning documents for semantic matches", + "think": "Planning next step", + } + label = labels.get(tool_name, f"Using {tool_name}") + if args and tool_name != "think": + # Try to extract the query/question from args JSON + try: + import json as _json + + parsed = _json.loads(args) + q = parsed.get("query") or parsed.get("question") or "" + if q: + label += f' — "{q[:50]}"' + except Exception: + pass + return label + + async def _stream_managed_agent( *, manager: AgentManager, @@ -534,11 +576,16 @@ async def _stream_managed_agent( if dr_tools: async def generate_deep_research(): - """Run DeepResearchAgent in a thread, stream result as SSE.""" + """Run DeepResearchAgent in thread, stream progress + result.""" import asyncio + import queue + import threading from openjarvis.agents.deep_research import DeepResearchAgent + progress_q: queue.Queue = queue.Queue() + + # Patch the agent's tool executor to emit progress dr_agent = DeepResearchAgent( engine=engine, model=model, @@ -547,48 +594,108 @@ async def _stream_managed_agent( temperature=float(config.get("temperature", 0.3)), ) - # Run agent in background thread - result = await asyncio.to_thread(dr_agent.run, user_content) - content = result.content or "No results found." + # Wrap the executor to capture tool calls + original_execute = dr_agent._executor.execute - # Stream the result word-by-word for SSE - words = content.split(" ") - for i, word in enumerate(words): - token = word if i == 0 else " " + word - chunk_data = { - "id": chunk_id, - "object": "chat.completion.chunk", - "model": model, - "choices": [ - { - "index": 0, - "delta": {"content": token}, - "finish_reason": None, - } - ], - } - yield f"data: {json.dumps(chunk_data)}\n\n" + def _tracked_execute(tc): + tool_name = tc.name + args_str = ( + tc.arguments[:80] if tc.arguments else "" + ) + progress_q.put({ + "type": "tool_start", + "tool": tool_name, + "args": args_str, + }) + result = original_execute(tc) + progress_q.put({ + "type": "tool_end", + "tool": tool_name, + "success": result.success, + }) + return result - # Final chunk - finish_data = { - "id": chunk_id, - "object": "chat.completion.chunk", - "model": model, - "choices": [ - { - "index": 0, - "delta": {}, - "finish_reason": "stop", + dr_agent._executor.execute = _tracked_execute + + def _run_agent(): + try: + result = dr_agent.run(user_content) + content = ( + result.content or "No results found." + ) + progress_q.put({ + "type": "done", + "content": content, + }) + except Exception as exc: + progress_q.put({ + "type": "error", + "content": f"Error: {exc}", + }) + + thread = threading.Thread(target=_run_agent, daemon=True) + thread.start() + + # Stream progress events and final content + while True: + try: + event = await asyncio.to_thread(progress_q.get, timeout=120) + except Exception: + # Timeout + yield _sse_chunk(chunk_id, model, "Agent timed out.") + break + + if event["type"] == "tool_start": + tool = event["tool"] + args = event.get("args", "") + label = _tool_progress_label(tool, args) + progress_data = { + "id": chunk_id, + "object": "chat.completion.chunk", + "model": model, + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": None, + "tool_progress": label, + } + ], } - ], - } - yield f"data: {json.dumps(finish_data)}\n\n" - yield "data: [DONE]\n\n" + yield f"data: {json.dumps(progress_data)}\n\n" - # Persist the response - manager.store_agent_response( - agent_id, message_id, content, - ) + elif event["type"] == "tool_end": + pass # Could emit completion signal + + elif event["type"] in ("done", "error"): + content = event["content"] + # Stream content word-by-word + words = content.split(" ") + for i, word in enumerate(words): + token = word if i == 0 else " " + word + yield _sse_chunk(chunk_id, model, token) + + # Final chunk + finish_data = { + "id": chunk_id, + "object": "chat.completion.chunk", + "model": model, + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop", + } + ], + } + yield f"data: {json.dumps(finish_data)}\n\n" + yield "data: [DONE]\n\n" + + # Persist + manager.store_agent_response( + agent_id, message_id, content, + ) + break return StreamingResponse( generate_deep_research(), From c079197c4e8e52b9805f214c4a4b4026f03df4de Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:26:57 -0700 Subject: [PATCH 27/32] fix: improve Interact tab UX and add real-time activity logging - Replace "Run Now" button with "Chat ready" hint when on the Interact tab so users know they can just type a message without pressing Run. - Add learning log entries in generate_deep_research() for query start, tool calls, tool results, query completion, and errors so interactive queries are visible in the Logs tab. - Rewrite LogsTab to poll every 5 seconds, merge execution traces with learning log entries into a unified timeline sorted by timestamp. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AgentsPage.tsx | 126 +++++++++++++++--- src/openjarvis/server/agent_manager_routes.py | 70 ++++++++++ 2 files changed, 181 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index 8d251855..e64b9c7d 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -1762,29 +1762,110 @@ function LearningTab({ agentId, learningEnabled }: { agentId: string; learningEn function LogsTab({ agentId }: { agentId: string }) { const [traces, setTraces] = useState([]); + const [learningEntries, setLearningEntries] = useState([]); const [expandedTrace, setExpandedTrace] = useState(null); - useEffect(() => { - fetchAgentTraces(agentId).then(setTraces).catch(() => {}); + const loadData = useCallback(async () => { + try { + const [t, l] = await Promise.all([ + fetchAgentTraces(agentId), + fetchLearningLog(agentId), + ]); + setTraces(t); + setLearningEntries(l); + } catch { + // ignore + } }, [agentId]); + useEffect(() => { + loadData(); + const interval = setInterval(loadData, 5000); + return () => clearInterval(interval); + }, [loadData]); + + // Merge traces and learning entries into a unified timeline + type TimelineEntry = + | { kind: 'trace'; data: AgentTrace; ts: number } + | { kind: 'learning'; data: LearningLogEntry; ts: number }; + + const timeline: TimelineEntry[] = [ + ...traces.map((t): TimelineEntry => ({ kind: 'trace', data: t, ts: t.started_at })), + ...learningEntries.map((e): TimelineEntry => ({ kind: 'learning', data: e, ts: e.created_at })), + ].sort((a, b) => b.ts - a.ts); + + const learningEventColor = (eventType: string) => { + if (eventType === 'query_start') return '#3b82f6'; + if (eventType === 'query_complete') return '#22c55e'; + if (eventType === 'tool_call') return '#f59e0b'; + if (eventType === 'tool_result') return '#8b5cf6'; + if (eventType === 'query_error') return '#ef4444'; + return 'var(--color-text-secondary)'; + }; + + const learningEventLabel = (eventType: string) => { + if (eventType === 'query_start') return 'Query'; + if (eventType === 'query_complete') return 'Complete'; + if (eventType === 'tool_call') return 'Tool Call'; + if (eventType === 'tool_result') return 'Tool Result'; + if (eventType === 'query_error') return 'Error'; + return eventType; + }; + return (
- Execution Traces + Activity Log - {traces.length} trace{traces.length !== 1 ? 's' : ''} + {timeline.length} entr{timeline.length !== 1 ? 'ies' : 'y'} (auto-refreshing)
- {traces.length === 0 ? ( + {timeline.length === 0 ? (
- No execution traces yet. Run the agent to generate traces. + No activity yet. Send a message or run the agent to generate logs.
) : (
- {traces.map((t) => { + {timeline.map((entry) => { + if (entry.kind === 'learning') { + const e = entry.data; + return ( +
+
+
+ + + {learningEventLabel(e.event_type)} + +
+ + {formatRelativeTime(e.created_at)} + +
+
+ {e.description} +
+
+ ); + } + + // Trace entry + const t = entry.data; const errorDetail = t.metadata?.error_detail as | { error_type: string; error_message: string; suggested_action: string } | undefined; @@ -1793,7 +1874,7 @@ function LogsTab({ agentId }: { agentId: string }) { return (
isError && errorDetail && setExpandedTrace(isExpanded ? null : t.id)} @@ -1805,6 +1886,12 @@ function LogsTab({ agentId }: { agentId: string }) { style={{ background: t.outcome === 'success' ? '#22c55e' : '#ef4444' }} /> {t.outcome} + + Trace + {errorDetail && ( {/* Header actions */}
- + {detailTab === 'interact' ? ( + + Chat ready — just type below + + ) : ( + + )} {(selectedAgent.status === 'running' || selectedAgent.status === 'idle') && ( +
+ )}
))} - {/* Progress indicator — shown when waiting but no streamed content yet, or alongside streaming */} - {(isAgentWorking || hasPending || waitingForResponse || sending) && !streamingContent && ( + {/* Progress indicator — shown when waiting but no streamed content yet */} + {(waitingForResponse || sending) && !streamingContent && (
{sending ? 'Sending message...' - : waitingForResponse - ? progressLabel || 'Initializing agent...' - : currentActivity || 'Agent is thinking...'} + : progressLabel || 'Agent is thinking...'}
@@ -1478,41 +1525,111 @@ function InlineConnectForm({ // Messaging tab component // --------------------------------------------------------------------------- -const MESSAGING_CHANNELS = [ +interface ChannelField { + key: string; + label: string; + placeholder: string; + type?: 'text' | 'password'; + required?: boolean; +} + +interface MessagingChannelConfig { + type: string; + name: string; + icon: string; + description: string; + setupSteps: string[]; + fields: ChannelField[]; + activeLabel: (cfg: Record) => string; + howToUse: (cfg: Record) => string; +} + +const MESSAGING_CHANNELS: MessagingChannelConfig[] = [ { - type: 'imessage', name: 'iMessage', icon: '\uD83D\uDCAC', - description: 'Text from your iPhone, iPad, or Mac', - activeTemplate: (id: string) => `Text ${id} from your iPhone`, - setupLabel: 'Phone number or email for the agent to use', - placeholder: '+15551234567', + type: 'imessage', + name: 'iMessage', + icon: '\uD83D\uDCAC', + description: 'Talk to your agent via iMessage from your iPhone or another Apple device', + setupSteps: [ + 'Your agent monitors iMessage on this Mac using the Messages app.', + 'Enter your phone number or Apple ID below — your agent will watch for texts from that contact.', + 'Then open iMessage on your iPhone and text this Mac. Your agent reads the message, researches your data, and responds in the same conversation.', + 'Requires macOS Full Disk Access + Accessibility permissions (System Settings \u2192 Privacy & Security).', + ], + fields: [ + { key: 'identifier', label: 'Your phone number or Apple ID', placeholder: '+15551234567 or you@icloud.com', required: true }, + ], + activeLabel: (cfg) => `Monitoring messages from ${(cfg.identifier as string) || '?'}`, + howToUse: (cfg) => `Open iMessage on your phone and text this Mac from ${(cfg.identifier as string) || 'your phone'}. Your agent will respond automatically.`, }, { - type: 'slack', name: 'Slack', icon: '#', - description: 'Message from any Slack workspace', - activeTemplate: (id: string) => `DM @jarvis in ${id}`, - setupLabel: 'Slack bot token (xoxb-...)', - placeholder: 'xoxb-...', + type: 'slack', + name: 'Slack', + icon: '#', + description: 'DM your agent in any Slack workspace', + setupSteps: [ + '1. Go to api.slack.com/apps \u2192 Create New App \u2192 From Scratch', + '2. Under OAuth & Permissions, add bot scopes: chat:write, channels:history, im:history, im:read', + '3. Install the app to your workspace and authorize it', + '4. Copy the Bot User OAuth Token (starts with xoxb-) from the OAuth page', + '5. For real-time DMs: enable Socket Mode, create an App-Level Token (starts with xapp-)', + ], + fields: [ + { key: 'bot_token', label: 'Bot Token', placeholder: 'xoxb-...', type: 'password', required: true }, + { key: 'app_token', label: 'App Token (optional \u2014 enables receiving DMs)', placeholder: 'xapp-...', type: 'password' }, + ], + activeLabel: () => 'Connected to Slack', + howToUse: () => 'Open Slack and DM @Jarvis to talk to your agent.', }, { - type: 'whatsapp', name: 'WhatsApp', icon: '\uD83D\uDCF1', - description: 'Message via WhatsApp', - activeTemplate: (id: string) => `Message ${id} on WhatsApp`, - setupLabel: 'WhatsApp access token', - placeholder: 'Access token', + type: 'whatsapp', + name: 'WhatsApp', + icon: '\uD83D\uDCF1', + description: 'Message your agent on WhatsApp \u2014 runs locally, no cloud API needed', + setupSteps: [ + 'OpenJarvis connects to WhatsApp using the Baileys protocol (local, on-device).', + 'Click Connect below. A QR code will appear in the server terminal.', + 'On your phone: open WhatsApp \u2192 Settings \u2192 Linked Devices \u2192 Link a Device, then scan the QR code.', + 'Once linked, send a WhatsApp message to the connected number to talk to your agent.', + ], + fields: [ + { key: 'assistant_name', label: 'Agent display name (optional)', placeholder: 'Jarvis' }, + ], + activeLabel: () => 'WhatsApp linked', + howToUse: () => 'Send a WhatsApp message to the linked number. Your agent will respond in the chat.', }, { - type: 'twilio', name: 'SMS (Twilio)', icon: '\uD83D\uDCE8', - description: 'Text from any phone via Twilio', - activeTemplate: (id: string) => `Text ${id} from any phone`, - setupLabel: 'Twilio phone number', - placeholder: '+15551234567', + type: 'twilio', + name: 'SMS', + icon: '\uD83D\uDCE8', + description: 'Text your agent from any phone via Twilio', + setupSteps: [ + '1. Create a free Twilio account at twilio.com/try-twilio', + '2. In the Twilio Console, buy a phone number (or use the trial number)', + '3. Copy your Account SID and Auth Token from the Console Dashboard', + '4. Enter all three values below', + 'After setup, text the Twilio number from any phone to talk to your agent.', + ], + fields: [ + { key: 'account_sid', label: 'Account SID', placeholder: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', required: true }, + { key: 'auth_token', label: 'Auth Token', placeholder: 'Your Twilio auth token', type: 'password', required: true }, + { key: 'phone_number', label: 'Twilio Phone Number', placeholder: '+15551234567', required: true }, + ], + activeLabel: (cfg) => { + const num = (cfg.phone_number as string) || ''; + return num ? `SMS active on ${num}` : 'SMS connected via Twilio'; + }, + howToUse: (cfg) => { + const num = (cfg.phone_number as string) || 'your Twilio number'; + return `Text ${num} from any phone to talk to your agent.`; + }, }, ]; function MessagingTab({ agentId }: { agentId: string }) { const [bindings, setBindings] = useState([]); const [setupType, setSetupType] = useState(null); - const [inputValue, setInputValue] = useState(''); + const [formValues, setFormValues] = useState>({}); const [loading, setLoading] = useState(false); const loadBindings = useCallback(() => { @@ -1521,15 +1638,27 @@ function MessagingTab({ agentId }: { agentId: string }) { useEffect(() => { loadBindings(); }, [loadBindings]); - const handleSetup = async (channelType: string) => { - if (!inputValue.trim()) return; + const setField = (key: string, value: string) => { + setFormValues((prev) => ({ ...prev, [key]: value })); + }; + + const handleSetup = async (ch: MessagingChannelConfig) => { + // Check required fields + const missing = ch.fields.filter( + (f) => f.required && !formValues[f.key]?.trim(), + ); + if (missing.length > 0) return; + setLoading(true); try { - await bindAgentChannel(agentId, channelType, { - identifier: inputValue.trim(), - }); + const config: Record = {}; + for (const f of ch.fields) { + const v = formValues[f.key]?.trim(); + if (v) config[f.key] = v; + } + await bindAgentChannel(agentId, ch.type, config); setSetupType(null); - setInputValue(''); + setFormValues({}); loadBindings(); } catch { /* */ } finally { setLoading(false); } }; @@ -1541,20 +1670,33 @@ function MessagingTab({ agentId }: { agentId: string }) { } catch { /* */ } }; + const inputStyle: React.CSSProperties = { + width: '100%', padding: '6px 10px', + background: 'var(--color-bg-secondary)', + border: '1px solid var(--color-border)', + borderRadius: 4, color: 'var(--color-text)', + fontSize: 12, boxSizing: 'border-box', + }; + return (
- Talk to your agent from your phone or other platforms + Connect a messaging platform so you can talk to your agent from your phone or other devices.
{MESSAGING_CHANNELS.map((ch) => { const binding = bindings.find((b) => b.channel_type === ch.type); - const identifier = binding?.config?.identifier as string || binding?.session_id || ''; + const cfg = (binding?.config || {}) as Record; const isSetup = setupType === ch.type; + // Check if required fields are filled + const canConnect = ch.fields.every( + (f) => !f.required || formValues[f.key]?.trim(), + ); + return (
+ {/* Header row */}
- {ch.icon} + {ch.icon}
{ch.name}
-
- {binding - ? ch.activeTemplate(identifier) - : 'Not set up'} + {binding ? ch.activeLabel(cfg) : ch.description}
{binding ? ( @@ -1588,7 +1729,7 @@ function MessagingTab({ agentId }: { agentId: string }) { Active
- {isSetup && ( + {/* Active state: how to use */} + {binding && (
{ch.setupLabel}
-
- setInputValue(e.target.value)} - placeholder={ch.placeholder} - style={{ - flex: 1, padding: '6px 10px', - background: 'var(--color-bg-secondary)', - border: '1px solid var(--color-border)', - borderRadius: 4, color: 'var(--color-text)', - fontSize: 12, - }} - onKeyDown={(e) => { - if (e.key === 'Enter') handleSetup(ch.type); - }} - /> - + fontSize: 11, color: 'var(--color-text-secondary)', + display: 'flex', alignItems: 'flex-start', gap: 6, + }}> + {'\u2192'} + {ch.howToUse(cfg)}
)} + + {/* Setup form */} + {isSetup && ( +
+ {/* Setup instructions */} +
+ {ch.setupSteps.map((step, i) => ( +
+ {step} +
+ ))} +
+ + {/* Form fields */} + {ch.fields.map((field) => ( +
+ + setField(field.key, e.target.value)} + placeholder={field.placeholder} + style={inputStyle} + /> +
+ ))} + + {/* Connect button */} + +
+ )}
); })}