feat: CC agent features, live call assist, worklist redesign, brand tokens

CC Agent:
- Call transfer (CONFERENCE + KICK_CALL) with inline transfer dialog
- Recording pause/resume during active calls
- Missed calls API (Ozonetel abandonCalls)
- Call history API (Ozonetel fetchCDRDetails)

Live Call Assist:
- Deepgram Nova STT via raw WebSocket
- OpenAI suggestions every 10s with lead context
- LiveTranscript component in sidebar during calls
- Browser audio capture from remote WebRTC stream

Worklist:
- Redesigned table: clickable phones, context menu (Call/SMS/WhatsApp)
- Last interaction sub-line, source column, improved SLA
- Filtered out rows without phone numbers
- New missed call notifications

Brand:
- Logo on login page
- Blue scale rebuilt from logo blue rgb(32, 96, 160)
- FontAwesome duotone CSS variables set globally
- Profile menu icons switched to duotone

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 10:36:10 +05:30
parent 99bca1e008
commit 3064eeb444
21 changed files with 2583 additions and 85 deletions

View File

@@ -0,0 +1,480 @@
# CC Agent Features — Phase 1 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 call transfer, recording pause, and missed call queue to the CC agent's call desk — the three most impactful features for daily workflow.
**Architecture:** Three new service methods in the NestJS sidecar (callControl, pauseRecording, getAbandonCalls), exposed via REST endpoints. Frontend adds Transfer and Pause Recording buttons to the active call card, and a missed call queue that pulls from Ozonetel instead of our webhook-created records.
**Tech Stack:** NestJS sidecar (Ozonetel Token Auth APIs), React 19 + Jotai + Untitled UI
**Ozonetel API endpoints used:**
- Call Control: `POST /ca_apis/CallControl_V4` — Token auth — CONFERENCE, HOLD, UNHOLD, MUTE, UNMUTE, KICK_CALL
- Recording: `GET /CAServices/Call/Record.php` — apiKey in query string — pause/unPause
- Abandon Calls: `GET /ca_apis/abandonCalls` — Token auth — missed calls list
---
## File Map
### Sidecar (helix-engage-server)
| File | Action |
|------|--------|
| `src/ozonetel/ozonetel-agent.service.ts` | Modify: add `callControl()`, `pauseRecording()`, `getAbandonCalls()` |
| `src/ozonetel/ozonetel-agent.controller.ts` | Modify: add `POST /api/ozonetel/call-control`, `POST /api/ozonetel/recording`, `GET /api/ozonetel/missed-calls` |
### Frontend (helix-engage)
| File | Action |
|------|--------|
| `src/components/call-desk/active-call-card.tsx` | Modify: add Transfer button + transfer input, Pause Recording button |
| `src/components/call-desk/transfer-dialog.tsx` | Create: inline transfer form (enter number, confirm) |
| `src/hooks/use-worklist.ts` | Modify: fetch missed calls from Ozonetel API instead of platform |
---
## Task 1: Add call control service methods
Three new methods in the Ozonetel service: `callControl()` (generic), `pauseRecording()`, and `getAbandonCalls()`.
**Files:**
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.service.ts`
- [ ] **Step 1: Add `callControl()` method**
```typescript
async callControl(params: {
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
ucid: string;
conferenceNumber?: string;
}): Promise<{ status: string; message: string; ucid?: string }> {
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
const did = process.env.OZONETEL_DID ?? '918041763265';
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';
this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);
try {
const token = await this.getToken();
const body: Record<string, string> = {
userName: this.accountId,
action: params.action,
ucid: params.ucid,
did,
agentPhoneName,
};
if (params.conferenceNumber) {
body.conferenceNumber = params.conferenceNumber;
}
const response = await axios.post(url, body, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
this.logger.log(`Call control response: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
this.logger.error(`Call control failed: ${error.message} ${responseData}`);
throw error;
}
}
```
- [ ] **Step 2: Add `pauseRecording()` method**
This uses apiKey in query params, not token auth:
```typescript
async pauseRecording(params: {
ucid: string;
action: 'pause' | 'unPause';
}): Promise<{ status: string; message: string }> {
const url = `https://${this.apiDomain}/CAServices/Call/Record.php`;
this.logger.log(`Recording ${params.action}: ucid=${params.ucid}`);
try {
const response = await axios.get(url, {
params: {
userName: this.accountId,
apiKey: this.apiKey,
action: params.action,
ucid: params.ucid,
},
});
this.logger.log(`Recording control response: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
this.logger.error(`Recording control failed: ${error.message} ${responseData}`);
throw error;
}
}
```
- [ ] **Step 3: Add `getAbandonCalls()` method**
```typescript
async getAbandonCalls(params?: {
fromTime?: string;
toTime?: string;
campaignName?: string;
}): Promise<Array<{
monitorUCID: string;
type: string;
status: string;
campaign: string;
callerID: string;
did: string;
agentID: string;
agent: string;
hangupBy: string;
callTime: string;
}>> {
const url = `https://${this.apiDomain}/ca_apis/abandonCalls`;
this.logger.log('Fetching abandon calls');
try {
const token = await this.getToken();
const body: Record<string, string> = { userName: this.accountId };
if (params?.fromTime) body.fromTime = params.fromTime;
if (params?.toTime) body.toTime = params.toTime;
if (params?.campaignName) body.campaignName = params.campaignName;
const response = await axios.get(url, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: body,
});
const data = response.data;
if (data.status === 'success' && Array.isArray(data.message)) {
return data.message;
}
return [];
} catch (error: any) {
this.logger.error(`Abandon calls failed: ${error.message}`);
return [];
}
}
```
- [ ] **Step 4: Type check and commit**
```bash
cd helix-engage-server && npx tsc --noEmit
```
```
feat: add call control, recording pause, and abandon calls to Ozonetel service
```
---
## Task 2: Add sidecar REST endpoints
**Files:**
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`
- [ ] **Step 1: Add `POST /api/ozonetel/call-control`**
```typescript
@Post('call-control')
async callControl(
@Body() body: {
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
ucid: string;
conferenceNumber?: string;
},
) {
if (!body.action || !body.ucid) {
throw new HttpException('action and ucid required', 400);
}
if (body.action === 'CONFERENCE' && !body.conferenceNumber) {
throw new HttpException('conferenceNumber required for CONFERENCE action', 400);
}
this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);
try {
const result = await this.ozonetelAgent.callControl(body);
return result;
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
throw new HttpException(message, error.response?.status ?? 502);
}
}
```
- [ ] **Step 2: Add `POST /api/ozonetel/recording`**
```typescript
@Post('recording')
async recording(
@Body() body: { ucid: string; action: 'pause' | 'unPause' },
) {
if (!body.ucid || !body.action) {
throw new HttpException('ucid and action required', 400);
}
try {
const result = await this.ozonetelAgent.pauseRecording(body);
return result;
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Recording control failed';
throw new HttpException(message, error.response?.status ?? 502);
}
}
```
- [ ] **Step 3: Add `GET /api/ozonetel/missed-calls`**
Import `Get` from `@nestjs/common`:
```typescript
@Get('missed-calls')
async missedCalls() {
const result = await this.ozonetelAgent.getAbandonCalls();
return result;
}
```
- [ ] **Step 4: Type check and commit**
```bash
cd helix-engage-server && npx tsc --noEmit
```
```
feat: add call control, recording, and missed calls REST endpoints
```
---
## Task 3: Add Transfer and Pause Recording to active call UI
During an active call, the agent gets two new buttons:
- **Transfer** — opens an inline input for the transfer number, then does CONFERENCE + KICK_CALL
- **Pause Rec** — toggles recording pause
**Files:**
- Create: `helix-engage/src/components/call-desk/transfer-dialog.tsx`
- Modify: `helix-engage/src/components/call-desk/active-call-card.tsx`
- [ ] **Step 1: Create transfer-dialog.tsx**
A simple inline form: text input for phone number + "Transfer" button. On submit, calls the sidecar's call-control endpoint twice: CONFERENCE (dial the target), then after confirming, KICK_CALL (drop the agent).
```typescript
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneArrowRight, faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
type TransferDialogProps = {
ucid: string;
onClose: () => void;
onTransferred: () => void;
};
export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogProps) => {
const [number, setNumber] = useState('');
const [transferring, setTransferring] = useState(false);
const [stage, setStage] = useState<'input' | 'connected'>('input');
const handleConference = async () => {
if (!number.trim()) return;
setTransferring(true);
try {
// Add the target to the conference
await apiClient.post('/api/ozonetel/call-control', {
action: 'CONFERENCE',
ucid,
conferenceNumber: `0${number.replace(/\D/g, '')}`,
});
notify.success('Connected', 'Third party connected. Click Complete to transfer.');
setStage('connected');
} catch {
notify.error('Transfer Failed', 'Could not connect to the target number');
} finally {
setTransferring(false);
}
};
const handleComplete = async () => {
setTransferring(true);
try {
// Drop the agent from the call — customer stays with the target
await apiClient.post('/api/ozonetel/call-control', {
action: 'KICK_CALL',
ucid,
conferenceNumber: `0${number.replace(/\D/g, '')}`,
});
notify.success('Transferred', 'Call transferred successfully');
onTransferred();
} catch {
notify.error('Transfer Failed', 'Could not complete transfer');
} finally {
setTransferring(false);
}
};
return (
<div className="mt-3 rounded-lg border border-secondary bg-secondary p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-secondary">Transfer Call</span>
<button onClick={onClose} className="text-fg-quaternary hover:text-fg-secondary">
<FontAwesomeIcon icon={faXmark} className="size-3" />
</button>
</div>
{stage === 'input' ? (
<div className="flex gap-2">
<Input
size="sm"
placeholder="Enter phone number"
value={number}
onChange={setNumber}
/>
<Button
size="sm"
color="primary"
isLoading={transferring}
onClick={handleConference}
isDisabled={!number.trim()}
>
Connect
</Button>
</div>
) : (
<div className="flex items-center justify-between">
<span className="text-xs text-tertiary">Connected to {number}</span>
<Button size="sm" color="primary" isLoading={transferring} onClick={handleComplete}>
Complete Transfer
</Button>
</div>
)}
</div>
);
};
```
- [ ] **Step 2: Add Transfer and Pause Recording buttons to active call card**
In `active-call-card.tsx`, add imports:
```typescript
import { faPhoneArrowRight, faRecordVinyl } from '@fortawesome/pro-duotone-svg-icons';
import { TransferDialog } from './transfer-dialog';
```
Add state:
```typescript
const [transferOpen, setTransferOpen] = useState(false);
const [recordingPaused, setRecordingPaused] = useState(false);
```
In the active call button row (around line 241), add two new buttons before the End button:
```typescript
<Button size="sm" color="secondary"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} />}
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
<Button size="sm" color={recordingPaused ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faRecordVinyl} className={className} />}
onClick={async () => {
const action = recordingPaused ? 'unPause' : 'pause';
if (callUcid) {
apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
}
setRecordingPaused(!recordingPaused);
}}>{recordingPaused ? 'Resume Rec' : 'Pause Rec'}</Button>
```
After the button row, before the AppointmentForm, add the transfer dialog:
```typescript
{transferOpen && callUcid && (
<TransferDialog
ucid={callUcid}
onClose={() => setTransferOpen(false)}
onTransferred={() => {
setTransferOpen(false);
hangup();
setPostCallStage('disposition');
}}
/>
)}
```
- [ ] **Step 3: Type check and commit**
```bash
cd helix-engage && npx tsc --noEmit
```
```
feat: add call transfer and recording pause to active call UI
```
---
## Task 4: Deploy and verify
- [ ] **Step 1: Build and deploy sidecar**
```bash
cd helix-engage-server && npm run build
# tar + scp + docker cp + restart
```
- [ ] **Step 2: Build and deploy frontend**
```bash
cd helix-engage
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com \
VITE_SIP_PASSWORD=523590 \
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 \
npm run build
```
- [ ] **Step 3: Test call transfer**
1. Place an outbound call
2. Click "Transfer" → enter a phone number → "Connect"
3. Third party should ring and join the call
4. Click "Complete Transfer" → agent drops, customer stays with target
5. Disposition form shows
- [ ] **Step 4: Test recording pause**
1. During an active call, click "Pause Rec"
2. Button changes to "Resume Rec" (destructive color)
3. Check Ozonetel reports — recording should have a gap
4. Click "Resume Rec" — recording resumes
- [ ] **Step 5: Test missed calls endpoint**
```bash
curl -s https://engage-api.srv1477139.hstgr.cloud/api/ozonetel/missed-calls | python3 -m json.tool
```
Verify it returns abandon call data from Ozonetel.
---
## Notes
- **Call Transfer is two-step**: CONFERENCE adds the target, KICK_CALL drops the agent. This is a "warm transfer" — all three parties are briefly connected before the agent drops. For "cold transfer" (blind), we'd CONFERENCE + immediately KICK_CALL without waiting.
- **Recording pause uses apiKey in query params** — different auth pattern from other `/ca_apis/` endpoints. This is the `/CAServices/` path.
- **KICK_CALL note from docs**: "Always pass the agent phone number in the conferenceNumber parameter to use KICK_CALL action." This means to drop the agent, pass the agent's phone number as conferenceNumber. To drop the transferred party, pass their number.
- **Missed calls API** — the `getAbandonCalls` returns today's data by default. For historical data, pass fromTime/toTime.
- **The active call button row is getting crowded** (Mute, Hold, Book Appt, Transfer, Pause Rec, End — 6 buttons). If this is too many, we can group Transfer + Pause Rec under a "More" dropdown.