DocHub
Photo library with send/purge, quick phrases toolbar, focus view, conversation import

WANK 3.0 — CRM Redesign, Deep Sync, Browser Safety & Tabbed CRM

Everything built on top of WANK 2 (documented in WANK-2.0.md). This covers the complete UI overhaul to a three-column CRM layout, deep media sync with WhatsApp history loading, archived chats support, browser page locking for concurrency safety, CRM panel media integration, and the tabbed CRM panel redesign with AI conversation summary, token tracking, and streamlined UI.


Table of Contents

  1. What Changed Since WANK 2
  2. Three-Column CRM Layout
  3. Deep Media Sync
  4. Browser Page Lock
  5. Archived Chats
  6. CRM Panel — Media Integration
  7. Media Browser Redesign
  8. Search Clear Safety
  9. Avatar Retry Service — Concurrency Fix
  10. Tabbed CRM Panel Redesign
  11. AI Conversation Summary
  12. AI Token Tracking
  13. UI Cleanup — Redundancy Removal
  14. Rebranding — IPNOELP CRM
  15. Files Modified
  16. Key Lessons & Gotchas

What Changed Since WANK 2

WANK 2 delivered AI auto-reply, contact management, backfill, avatar caching, and multi-slice deployment. WANK 3 transforms the interface from a WhatsApp clone into a proper CRM tool, and adds the infrastructure to safely share a single headless browser across multiple concurrent operations.

Category What Was Added
CRM Layout Three-column persistent layout (contact list / chat / CRM sidebar) replacing overlay panels
TopBar Redesign Simplified navigation with brand label, status pill, away mode indicator
Contact List Filter chips (All/Warm/Action/New), resizable width, tag-based filtering, edge-to-edge avatars
CRM Panel Persistent right sidebar with contact hero, actions/reminders, photos, contact details, notes, activity
Deep Media Sync Opens chat in headless browser, clicks “load older messages”, scrolls back through history, then syncs media
Archived Chats Filter chip to view archived WhatsApp contacts, reads from archived list DOM
Page Lock Mutex on the Puppeteer page — navigation operations acquire exclusive access, read-only operations bail if busy
Search Clear Robust cleanup after deep sync — clicks back arrow, verifies search box is empty, retries up to 5 times
Media Refresh CRM panel photos auto-refresh after sync/deep sync completes
Media Browser Redesigned left sidebar with avatars, zoom controls, DB-enriched contact names and phone numbers
Theme Update IBM Plex Sans/Mono fonts, refined dark color palette, new tokens for warnings/accents

Three-Column CRM Layout

Before (WANK 2)

  • Two-column layout: contact list (420px) + chat
  • CRM info hidden behind a ContactProfile overlay panel
  • ActionBar component consumed vertical space below the TopBar
  • Contact list items had pin/camera/contact buttons cluttering the UI

After (WANK 3)

┌──────────────────────────────────────────────────────────────────┐
│ OMELAS CRM   ● READY   +504...   67 chats          [Nav Btns]  │  ← TopBar (44px)
├───────────┬──────────────────────────────┬───────────────────────┤
│ [Filters] │  Contact Name                │ CRM PROFILE           │
│ All Warm  │  ~pushname · +phone          │ Avatar | Name         │
│ Action New│                              │        | +phone       │
│ Archived  │  ┌─────────────────────┐     │        | [Tags]       │
│───────────│  │ Message bubbles     │     │                       │
│ Contact 1 │  │                     │     │ ⚡ ACTIONS            │
│ Contact 2 │  │                     │     │ □ Follow up           │
│ Contact 3 │  │                     │     │                       │
│ Contact 4 │  │                     │     │ 📷 PHOTOS             │
│ ...       │  │                     │     │ [thumb][thumb][thumb]  │
│           │  └─────────────────────┘     │                       │
│           │  [Message Composer]           │ 👤 DETAILS            │
│           │                              │ 📝 NOTES              │
├───────────┴──────────────────────────────┴───────────────────────┤
│  280-340px        flexible                     440px             │
└──────────────────────────────────────────────────────────────────┘

Implementation

ChatPage.tsx — CSS grid layout:

<div className="h-full grid" style={{ gridTemplateColumns: `${contactListWidth}px 1fr 440px` }}>
  <ChatList />         {/* Column 1: Contact list */}
  <ConversationView /> {/* Column 2: Chat */}
  <CrmPanel />         {/* Column 3: CRM sidebar */}
</div>

Key changes:

  • ActionBar.tsx removed from App.tsx — sync media moved to ConversationView header
  • ContactProfile.tsx overlay replaced by persistent CrmPanel.tsx component
  • Contact list width is resizable (stored in localStorage)
  • TopBar.tsx simplified: brand label “OMELAS CRM”, status pill, phone/count, away toggle, nav buttons

Deep Media Sync

Problem

Normal media sync (Sync button) only finds media in recently loaded messages. For contacts with long history, older media isn’t accessible without scrolling back through WhatsApp Web’s conversation.

Solution

A Deep button next to Sync that opens the chat in the headless browser, triggers WhatsApp’s “load older messages” feature, scrolls up to load history, then runs the standard media sync with a higher message limit.

How It Works

User clicks "Deep" button
    ↓
Frontend: mediaAPI.syncMedia(chatId, 1000, deep=true)
    ↓
Backend: domReader.deepLoadChat(chatId)
    ↓
1. Type phone number into WhatsApp search box
   → Uses page.keyboard.type() on [contenteditable][data-tab="3"]
    ↓
2. Click the matching search result
   → Find [role="row"] containing span[title] (not header rows)
   → Use page.mouse.click() (DOM click doesn't trigger WA navigation)
    ↓
3. Click "Click here to get older messages from your phone" button
   → Uses page.mouse.click() on button coordinates
    ↓
4. Scroll up to load more history
   → page.mouse.wheel({ deltaY: -600 }) in loop
   → Track progress via document.querySelectorAll('#main [data-id]').length
   → Stop after 4 passes with no new messages
    ↓
5. Clear search to restore normal chat list
   → clearSearch() — clicks back arrow, verifies empty, retries
    ↓
6. Standard fetchMessages(limit=1000) + downloadMedia() pipeline
    ↓
Response: { totalMessages, downloaded, mediaMessages, deep: { scrollPasses } }

Frontend UI

Two buttons in ConversationView header:

Button Label Action Timeout
Sync <Download> icon + “Sync” syncMedia(chatId, 200) 30s
Deep <SearchX> icon + “Deep” syncMedia(chatId, 1000, true) 120s

Both buttons disabled while either operation is running. Status text shows results (e.g., “42 msgs scanned, 3 downloaded, 5 media”).


Browser Page Lock

Problem

The WhatsApp backend has a single Puppeteer page (headless browser tab) shared by multiple services:

  • Deep sync — navigates to a chat via search, scrolls through history
  • Archived chats — clicks the archive button, reads the archived list, clicks back
  • Avatar retry — reads the chat list DOM every 60 minutes to find new avatars
  • Backfill — reads contacts and messages from the DOM
  • getChats() — reads the current chat list grid

When deep sync is running, it types in the search box and navigates away from the normal chat list. If getChats() or avatar retry runs simultaneously, they read the filtered/wrong DOM and return garbage data.

Solution

A mutex-style page lock on DOMReader:

// DOMReader.ts
private _pageBusy = false;
private _pageBusyReason = '';

acquirePageLock(reason: string): boolean {
  if (this._pageBusy) return false;  // Already locked
  this._pageBusy = true;
  this._pageBusyReason = reason;
  return true;
}

releasePageLock(): void {
  this._pageBusy = false;
  this._pageBusyReason = '';
}

Lock Usage

Operation Lock Behavior
deepLoadChat() Acquires lock at start, releases in finally block
getArchivedChats() Acquires lock at start, releases in finally block
getChats() Checks lock — returns [] if busy
AvatarRetryService.tick() Checks domReader.pageBusy — skips entire cycle if busy

Navigation operations (that change what’s visible on screen) acquire exclusive access. Read-only operations (that just scan what’s on screen) bail immediately if the page is busy.


Archived Chats

Problem

WhatsApp Web has an “Archived” section at the top of the chat list. Users wanted to see archived contacts in the CRM.

Solution

An “Archived” filter chip in the contact list that fetches archived chats from WhatsApp Web’s archived view.

How It Works

User clicks "Archived" filter chip
    ↓
Frontend: chatAPI.getArchivedChats()
    ↓
Backend: domReader.getArchivedChats()
    ↓
1. Acquire page lock
    ↓
2. Find the archive button in #pane-side
   → document.querySelector('[data-icon="archive-refreshed"]')
   → Walk up DOM to find parent BUTTON element
   → page.mouse.click() on button center coordinates
    ↓
3. Wait for archived list to render (1.5s)
    ↓
4. Read archived chat rows from [role="listitem"] elements
   → Extract: chatId (from React fiber), name, avatarUrl, lastMessage, timestamp, unreadCount
    ↓
5. Click back button to restore normal chat list
   → Find [data-icon="back"] or [aria-label="Back"]
    ↓
6. Release page lock
    ↓
Response: ChatListRow[] (same shape as normal chat list)

DOM Details

Element Selector Notes
Archive button [data-icon="archive-refreshed"] At TOP of chat list, not bottom
Archive list items [role="listitem"] Different from normal chat list [role="row"]
Back button [data-icon="back"] Appears in archived view header
Chat ID React fiber memoizedProps.chat.id._serialized Same extraction as normal chats

Important: The archived row is at the top of the chat list, not the bottom. The archived list uses role="listitem" not role="row".


CRM Panel — Media Integration

Problem

The CRM panel’s Photos section wasn’t loading for most contacts because it was stripping @c.us from the chatId before calling the media API. The backend’s resolveFolder() function needs the full wa_id (with @c.us) to look up the actual media folder name in the database.

Example: chatId 639254548862@c.us → DB lookup → folder +639254548862_Michelle_Nuez

The CRM panel was passing 639254548862 (no @c.us), which couldn’t be resolved.

Fix

  • Pass full chatId to mediaAPI.getContactMedia() instead of stripping @c.us
  • Use the folder field from the API response (resolved folder name) for thumbnail URLs and navigation links
  • Store resolved folder name in component state

Media Refresh After Sync

When the user runs Sync or Deep Sync from the ConversationView, the CRM panel now automatically reloads its Photos section.

Signal chain:

ConversationView: sync completes → onMediaSynced()
    ↓
ChatPage: setMediaRefreshKey(k => k + 1)
    ↓
CrmPanel: useEffect on mediaRefreshKey → loadPhotos()

Media Browser Redesign

Before

The Media Browser’s left sidebar showed only folder names (e.g., +116281978187830_Beatriz) with file counts and storage size in plain text. No avatars, no zoom controls, and phone numbers were often wrong — @lid contacts displayed their internal WhatsApp LID number instead of their real phone number.

After

The sidebar now matches the main ChatList format exactly:

┌───────────────────────────────┐
│ 💬 MEDIA          [- zoom +] │  ← Header (unscaled)
│ [🔍 Search contacts...]      │
│ [Find Duplicates]             │
├───────────────────────────────┤
│ ┌──────┬─────────────────┬──┐ │  ← Scaled by zoom
│ │ AVTR │ Michelle Nuez   │3P│ │
│ │ 60x60│ +639254548862   │2V│ │
│ │      │ 47 files · 12MB │  │ │
│ ├──────┼─────────────────┼──┤ │
│ │ AVTR │ Beatriz         │5P│ │
│ │ 60x60│ +639705644674   │1A│ │
│ │      │ 12 files · 4MB  │  │ │
│ └──────┴─────────────────┴──┘ │
└───────────────────────────────┘

Changes

Contact identity from DB — The listContacts endpoint now queries the contacts table to enrich each media folder with:

  • waId — full WhatsApp ID (used for avatar URL)
  • contactName — saved name or push name from DB
  • phoneNumber — real phone number from DB (not the folder-derived LID)

Avatars — 60x60 square edge-to-edge avatars using contactAPI.getAvatarUrl(waId), with initial-letter fallback for contacts without avatars. Uses object-cover to prevent distortion, with explicit w-[60px] h-[60px] dimensions and self-center to maintain square aspect ratio.

Zoom controlsuseZoom('media-browser-zoom') hook with ZoomControls component in the header. The zoom scale property is applied to the scrollable contact list via CSS zoom. The sidebar width expands dynamically: width = 300 + zoom.offset * 20 pixels, matching the main ChatList behavior.

Search — Filters on contact name (from DB), folder-derived name, real phone number (from DB), and folder-derived phone number.

Category breakdown — Right column shows compact counts: 3P, 2V, 1D, 1A for photos, videos, docs, audio.

Phone Number Resolution

Contact Type Folder Name Phone Source
@c.us +639625840831_Lanie Folder name matches real phone — works either way
@lid +116281978187830_Beatriz LID number in folder, must use contacts.phone_number from DB

The @lid problem: WhatsApp’s internal LID numbers (e.g., 116281978187830) are not phone numbers. When MediaService creates folders for @lid contacts, it uses the LID as the “phone” portion of the folder name. The Media Browser now prefers phoneNumber from the DB query over the folder-derived number.

Implementation

Backend (mediaController.tslistContacts):

const dbResult = await pool.query(
  `SELECT wa_id, media_folder, saved_name, push_name, phone_number
   FROM contacts WHERE media_folder IS NOT NULL`
);
const folderToContact = new Map();
for (const row of dbResult.rows) {
  folderToContact.set(row.media_folder, {
    wa_id: row.wa_id,
    name: row.saved_name || row.push_name || '',
    phone: row.phone_number || null
  });
}
// Each contact in response includes: waId, contactName, phoneNumber

Frontend (MediaBrowserPage.tsx):

const zoom = useZoom('media-browser-zoom');
const sidebarWidth = 300 + zoom.offset * 20;

// Sidebar uses dynamic width
<div style={{ width: sidebarWidth }}>
  // Header with ZoomControls (not scaled)
  // Scrollable list with zoom: zoom.scale (scaled)
</div>

Type update (types/index.ts):

export interface MediaContact {
  folder: string;
  waId: string | null;        // NEW — for avatar URL
  contactName: string | null;  // NEW — DB name
  phoneNumber: string | null;  // NEW — real phone from DB
  counts: { photos: number; videos: number; docs: number; audio: number; links: number; };
  totalFiles: number;
  transcriptLines: number;
  totalSize: number;
}

Search Clear Safety

Problem

Deep sync types a phone number into WhatsApp’s search box to find the contact. After finishing, a single Escape key press wasn’t reliably clearing the search. This left WhatsApp in a filtered state showing only the searched contact, making all other chats disappear.

Solution

A robust clearSearch() method that:

  1. Detects if search is active — looks for [data-icon="back"] button or text in search input
  2. Clicks the back arrow if visible (most reliable way to exit search)
  3. Falls back to Escape key press if no back arrow found
  4. Retries up to 5 times with 500ms delays
  5. Final fallback — directly clears the search box content and fires input event
  6. Verifies the search is actually cleared before returning
async clearSearch(): Promise<boolean> {
  for (let attempt = 0; attempt < 5; attempt++) {
    // Check if search is still active
    const hasSearch = /* look for back arrow or text in search box */;
    if (!hasSearch) return true;  // Cleared!

    // Click back arrow (preferred) or press Escape (fallback)
    const backBtn = /* find [data-icon="back"] */;
    if (backBtn) await page.mouse.click(backBtn.x, backBtn.y);
    else await page.keyboard.press('Escape');

    await sleep(500);
  }
  // Final fallback: clear search box content directly
}

Used by deepLoadChat() in both success and error paths.


Avatar Retry Service — Concurrency Fix

Before

AvatarRetryService ran every 60 minutes and called domReader.getChats() to scan the chat list for avatar URLs. If deep sync or archived view was active, it would read the wrong DOM and potentially cache garbage data.

After

Two safety checks:

  1. AvatarRetryService.tick() checks domReader.pageBusy at the start — if the page is locked by any navigation operation, the entire tick is skipped and retried next hour.

  2. getChats() checks _pageBusy — if locked, returns empty array immediately instead of reading potentially wrong DOM.

Resource Usage

The service is lightweight by design:

Setting Value Effect
Interval 60 minutes Only runs once per hour
Batch size 10 Checks max 10 contacts per pass
Max retries 4 Gives up permanently after 4 failures per contact
Requires connection Yes Skips if WhatsApp not connected
Requires idle page Yes Skips if page is locked

Once all contacts have either received their avatar or exhausted 4 retries, the service does zero work each tick.


Tabbed CRM Panel Redesign

Before

The CRM panel was a long vertical scroll of collapsible sections (Actions & Reminders, Photos, Contact Details, AI Auto-Reply, Activity). Opening Contact Details revealed a massive form. AI sections pushed content lower. Everything competed for space.

After

┌─────────────────────────────────────┐
│ Avatar | Name, phone, tags          │  ← Hero (unchanged)
│        | 💕 Girlfriend  🔔          │  ← Personality + bell icon
│        | ⊕ Add location             │
├─────────────────────────────────────┤
│ [ Notes ] [ Photos ] [ Contact ] [ AI ] │  ← Tab bar
├─────────────────────────────────────┤
│                                     │
│ Tab content (scrollable)            │  ← Active tab content
│                                     │
└─────────────────────────────────────┘

Tab Structure

Tab Default Content
Notes Yes AI Summarise button + notes textarea (auto-save)
Photos “Browse All Media” button + photo thumbnail grid
Contact Collapsible twisties: Details, Dates, Detail Tokens, Tags
AI AnalysePanel + PersonalityPicker + Reminders/Actions

Notes Tab

  • AI Summarise at the top — click to generate a conversation summary via whereWereWe API
  • Summary includes context from the notes field (passed to backend as notes parameter)
  • “Copy to notes” button prepends summary to the notes textarea with date stamp
  • Notes textarea below with auto-save (debounced 1.5s), tall (min-h-[320px]), scrollable
  • Token count shown next to summary (e.g., “342tk”)

Contact Tab — Collapsible Twisties

The old single massive form is split into collapsible sections using a Twisty component:

Section Default State Fields
Details Open Name, company, email, location met, social handles
Dates Closed Birthday, anniversary, contact cycle
Detail Tokens Closed AI-extracted personal details (Pets, Kids, Work, etc.)
Tags Closed Tag management with badge count

AI Tab

Combines all AI-related features:

  1. AnalysePanel — AI analysis of conversation to extract contact details
  2. PersonalityPicker — assign/change/pause AI auto-reply personality
  3. Reminders — create, view, complete actions/reminders

Avatar Lightbox

Clicking the avatar in the CRM hero opens a fullscreen lightbox overlay instead of a new browser tab. Dismiss by clicking backdrop or X button.


AI Conversation Summary

Backend

POST /api/contacts/:waId/where-were-we now accepts an optional notes parameter in the request body. When provided, the notes are included in the AI system prompt:

"You also have these personal notes/facts about {contactName}:
{notes}

Incorporate any relevant notes into the summary."

This gives the AI context about the relationship when summarising the conversation.

Frontend — Notes Tab Integration

The summary flow:

  1. User clicks “Summarise conversation…” button
  2. Frontend calls contactAPI.whereWereWe(chatId, undefined, notes) — passing current notes
  3. Loading spinner shown during API call
  4. Summary displayed in purple card with token count and response time
  5. “Copy to notes” button prepends [Summary YYYY-MM-DD]\n{summary}\n\n to notes
  6. “Refresh” re-runs the summary; “Dismiss” hides it

AI Token Tracking

Backend

AIService singleton tracks cumulative session usage:

  • _sessionTokens — total tokens used since backend started
  • _sessionCalls — number of AI API calls made
  • _sessionStartedAt — ISO timestamp of backend start

New endpoint: GET /api/ai/token-usage returns { tokens, calls, startedAt }.

Frontend — TopBar

The TopBar polls /api/ai/token-usage every 30 seconds and displays a compact token counter:

  • Format: 340 tk or 1.2k tk (thousands)
  • Hover tooltip shows number of AI calls
  • Only visible when tokens > 0

UI Cleanup — Redundancy Removal

Conversation Header (Column 2) — Simplified

Removed redundant elements from the conversation header:

Removed Reason
Contact name + pushname + phone Already shown in CRM panel (column 3)
Personality badge (emoji + name) Shown in chat list (column 1) and CRM hero (column 3)
“AI not set” badge Not useful inline
Actions count button Bell icon in chat list + CRM hero covers this
Sync Media button Redundant with “earlier messages” button

Kept: Import Chat button (renamed from “Import”), zoom controls.

Chat List (Column 1) — Icon Consistency

  • Action indicator: “ACT” text badge replaced with bell icon (w-4 h-4, amber)
  • “action” tag: Filtered out of tags row — bell icon is the sole indicator
  • Bell + personality emoji: Combined on one line in the right column (bell left, emoji right)
  • Avatar fallback: Shows initial letter only (personality emoji removed from avatar area)
  • All right-column icons standardized to w-4 h-4 (16px) for visual consistency

CRM Panel Hero (Column 3)

  • Personality emoji + name + bell icon retained
  • Personality changes trigger live refresh of chat list via onPersonalityChanged callback chain

Rebranding — IPNOELP CRM

Brand label changed from “OMELAS CRM” to “IPNOELP CRM” in:

  • TopBar.tsx — persistent header
  • ChatPage.tsx — empty state when no contact selected

Files Modified

New Files

File Purpose
frontend/src/components/CrmPanel.tsx Persistent right sidebar with CRM data, photos, actions, notes

Modified Files — Backend

File Changes
backend/src/services/DOMReader.ts Page lock mechanism, clearSearch(), deepLoadChat(), getArchivedChats(), archived button finder fix, busy checks on getChats()
backend/src/services/AvatarRetryService.ts Page busy check in tick() — skips when browser is locked
backend/src/services/AIService.ts Session token tracking (_sessionTokens, _sessionCalls, _sessionStartedAt), getSessionUsage() method
backend/src/controllers/mediaController.ts deep query parameter for sync endpoint, deep load integration, DB enrichment in listContacts (waId, contactName, phoneNumber)
backend/src/controllers/aiCrmController.ts whereWereWe accepts notes body parameter, includes in AI system prompt
backend/src/routes/aiRoutes.ts Added GET /ai/token-usage endpoint

Modified Files — Frontend

File Changes
frontend/src/pages/ChatPage.tsx Three-column CSS grid layout, CrmPanel integration, media refresh key signal, assignmentRefreshKey for personality live-refresh, brand label “IPNOELP CRM”
frontend/src/components/TopBar.tsx Brand label “IPNOELP CRM”, status pill, simplified nav, away mode indicator, AI token counter (polls every 30s)
frontend/src/components/ChatList.tsx Filter chips (All/Warm/Action/New/Archived), resizable width, tag-based filtering, assignmentRefreshKey prop for live personality updates
frontend/src/components/ChatListItem.tsx Edge-to-edge avatars, tags, bell icon for actions (replaced “ACT” text), personality emoji + bell on same line in right column, “action” tag filtered from tags row, all icons standardized to w-4 h-4
frontend/src/components/ConversationView.tsx Removed contact name/phone/personality badge/Actions button/Sync button from header, renamed Import to “Import Chat”
frontend/src/components/CrmPanel.tsx Major rewrite: tabbed layout (Notes/Photos/Contact/AI), AI summary with copy-to-notes, avatar lightbox, notes management, onPersonalityChanged callback
frontend/src/components/ContactEditForm.tsx Removed duplicate Reminders section, removed AnalysePanel, removed Notes textarea (moved to CrmPanel Notes tab), restructured with collapsible Twisty components
frontend/src/components/PersonalityPicker.tsx Added onChanged callback prop, called after personality assignment/removal
frontend/src/pages/MediaBrowserPage.tsx Redesigned left sidebar: avatars, zoom controls, DB-enriched names/phones, category breakdown, dynamic width
frontend/src/types/index.ts MediaContact type: added waId, contactName, phoneNumber fields
frontend/src/services/api.ts syncMedia accepts deep parameter with 120s timeout, getArchivedChats(), aiAPI.getTokenUsage(), contactAPI.whereWereWe() accepts notes parameter
frontend/tailwind.config.js New color tokens, IBM Plex font families
frontend/src/index.css IBM Plex font imports, scrollbar styling
frontend/index.html Google Fonts preconnect

Key Lessons & Gotchas

1. WhatsApp Archived Row is at the TOP

The “Archived” row appears at the top of the chat list, above all regular chats — not at the bottom. Scrolling to the bottom to find it wastes time and fails.

2. Archived List Uses role="listitem", Not role="row"

Normal chat list items are [role="row"] inside [role="grid"]. Archived view items are [role="listitem"] — a completely different DOM structure.

3. Archive Button Has No Text

The archived button is identified by [data-icon="archive-refreshed"], not by span text containing “Archived”. The icon is an SVG, the parent is a BUTTON element.

After typing in the WhatsApp search box and opening a chat, pressing Escape once doesn’t reliably clear the search. The clearSearch() method tries the back arrow first, retries multiple times, and verifies the result.

5. page.mouse.click() vs element.click()

WhatsApp Web’s React event handlers don’t respond to programmatic DOM click() calls. All navigation clicks must use page.mouse.click(x, y) via Puppeteer — this simulates a real user interaction that WhatsApp’s event system recognizes.

6. Media Folder Resolution Requires Full wa_id

The resolveFolder() function in mediaController.ts needs the full chatId with @c.us suffix to look up the actual folder name from the database. Stripping @c.us before calling it causes a lookup miss, returning an empty folder.

7. One Browser, Many Services

The headless Puppeteer page is shared by deep sync, archived view, avatar retry, backfill, and regular chat list reads. Without the page lock, simultaneous operations read wrong DOM state. Navigation operations must acquire exclusive access; read-only operations must bail when locked.

8. WhatsApp LID Numbers Are Not Phone Numbers

@lid contacts have internal WhatsApp IDs (e.g., 116281978187830) that look like phone numbers but aren’t. When folder names are created from these IDs (e.g., +116281978187830_Beatriz), the “phone” portion is meaningless to users. Always prefer contacts.phone_number from the database over folder-derived numbers.

9. Avatar Containers Must Be Explicitly Square

When using items-stretch on a flex parent, child containers stretch vertically to match the tallest sibling. A 60px-wide avatar container becomes a tall rectangle, distorting object-cover images. Fix: use explicit w-[60px] h-[60px] with self-center and items-center on the parent row instead of items-stretch.

10. Personality Refresh Requires Cross-Component Signaling

When the user changes a contact’s AI personality in the CRM panel (column 3), the chat list (column 1) must update its emoji. Since they’re sibling components, use a counter (assignmentRefreshKey) in the parent ChatPage, bumped by onPersonalityChanged callback, passed to ChatList as a dependency for its loadAssignments effect.