Skip to content

Camada de CRM para assessores sobre o clone do WhatsApp - #4

Open
matheusfrainer wants to merge 2 commits into
mainfrom
feat/crm-assessoria
Open

Camada de CRM para assessores sobre o clone do WhatsApp#4
matheusfrainer wants to merge 2 commits into
mainfrom
feat/crm-assessoria

Conversation

@matheusfrainer

@matheusfrainer matheusfrainer commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Acrescenta ao clone do WhatsApp uma camada de CRM para assessores de
investimento: quatro áreas de largura total (funil, carteiras, agenda e
automações), um painel de cliente ao lado da conversa e uma leitura da conversa
por LLM. Substitui também o README do projeto por um CLAUDE.md.

O que muda

  • Funil, carteiras, agenda e automações — áreas novas na nav rail, que
    ocupam a largura toda porque um kanban e uma tabela precisam dela.
  • Painel do cliente — coluna fixa a partir de xl, sheet nos viewports
    menores; nunca os dois no DOM ao mesmo tempo. Cadastro, perfil de investidor,
    posições, automações do contato e a aba de IA.
  • Análise por LLMPOST /api/insights chama a API da Anthropic com JSON
    Schema no output_config e o prompt de sistema atrás de um breakpoint de
    cache.

Como continua funcionando sem chave

Não há backend: o estado todo vive em localStorage. A rota de insights é o
único código de servidor, e sem ANTHROPIC_API_KEY ela responde 501 — a UI cai
para a heurística local (lib/insights-local.ts), então a aba de IA nunca fica
vazia. O resultado do modelo é cacheado por fingerprint(chat), de modo que
alternar de conversa e voltar não gasta requisição.

Decisões que valem a revisão

  • O estado inicial é vazio por construção. O servidor pré-renderiza a página
    e não lê localStorage; qualquer dado de domínio no estado inicial pinta
    primeiro e se reescreve depois. O seed passa a entrar pelo HYDRATE, no
    cliente. e2e/hydration.spec.ts trava o contrato — inclusive que nome do seed
    nenhum apareça no HTML pré-renderizado.
  • Automação global × local. Ligar ou desligar uma regra global para um
    contato grava um override em chat.automationOverrides; a regra compartilhada
    nunca é mutada.
  • Chave de storage em v3. CrmData mudou de forma de maneira incompatível
    e não há migração: payload antigo cai para o seed.
  • O scheduler só roda com a aba aberta e apenas propõe — quem aprova é a
    Agenda. Não existe fila no servidor, e a UI evita sugerir que exista.

Verificação

npm run typecheck e npm run lint limpos; 74 testes e2e verdes (37 specs
× desktop e mobile), rodando contra o build de produção.

Nota para quem for rodar prettier --check no Windows: com core.autocrlf=true
ele reprova o repositório inteiro, porque o .prettierrc fixa endOfLine: "lf"
e a árvore de trabalho é CRLF. É falso positivo — está documentado no
CLAUDE.md.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added CRM workspace views for funnels, portfolios, agendas, automations, and campaigns.
    • Added contact profiles, tags, meetings, notes, investor suitability, and portfolio management.
    • Added automation scheduling, approvals, campaigns, and personalized message suggestions.
    • Added AI chat insights with local analysis and optional generated recommendations.
  • Improvements
    • Added responsive CRM panels, improved chat sorting, loading placeholders, and message layout.
    • Added portfolio metrics, goals, filters, bulk actions, and campaign recipient previews.
  • Bug Fixes
    • Improved message ordering, scroll positioning, hydration, and persisted state handling.

Matheus Frainer and others added 2 commits August 8, 2026 11:20
Acrescenta ao clone quatro áreas de largura total (funil, carteiras, agenda
e automações), um painel de cliente ao lado da conversa e uma leitura da
conversa por LLM.

Continua sem backend: o estado todo vive em localStorage. A única rota de
servidor é POST /api/insights, que chama a API da Anthropic com JSON Schema
no output_config. Sem ANTHROPIC_API_KEY ela responde 501 e a UI cai para a
heurística local, de modo que a aba de IA nunca fica vazia.

Decisões que o código sozinho não entrega:

- O estado inicial é vazio por construção. A página é pré-renderizada no
  servidor, que não lê localStorage, então qualquer dado de domínio no estado
  inicial pinta primeiro e se reescreve depois. O seed passa a entrar pelo
  HYDRATE, no cliente; e2e/hydration.spec.ts trava esse contrato, inclusive
  que nome do seed nenhum apareça no HTML pré-renderizado.
- Regras de automação globais vivem no store e as de contato vivem no chat.
  Ligar ou desligar uma global para um contato grava um override, nunca muta
  a regra compartilhada.
- A chave de storage vai para v3: CrmData mudou de forma de maneira
  incompatível e não há migração, então um payload antigo cai para o seed.
- O scheduler só roda com a aba aberta e apenas propõe; quem aprova é a
  Agenda. Não há fila do lado do servidor e a UI diz isso.

37 testes e2e em 5 specs, verdes em desktop e mobile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
O README descrevia só o clone de chat e já não mencionava CRM, funil,
carteiras, agenda, automações nem a análise por LLM — metade do projeto.
Em vez de reescrevê-lo, o projeto passa a ter um CLAUDE.md, que é o que de
fato se usa aqui.

O arquivo cobre comandos, o mapa dos diretórios, as regras não óbvias
(contrato de hidratação, versionamento da chave de storage, override das
automações, o modo determinístico dos testes com ?e2e=1) e as convenções —
sem repetir o que o código já diz.

Dois fatos que só existiam no README foram preservados: a fonte Geist vem
dos itens oficiais do registry, e Status e Canais ficaram intencionalmente
fora do clone. Sem o segundo, a ausência deles se lê como pendência.

O README da raiz perde o link para o arquivo removido.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The WhatsApp application now includes a CRM domain, automation workflows, local and Anthropic-backed insights, advisory workspaces, responsive CRM panels, expanded persistence, shared UI primitives, and Playwright coverage.

Changes

WhatsApp CRM application

Layer / File(s) Summary
CRM domain and persisted state
whatsapp/lib/types.ts, whatsapp/lib/crm.ts, whatsapp/lib/portfolio.ts, whatsapp/lib/store.tsx, whatsapp/lib/storage.ts, whatsapp/lib/data.ts
Adds CRM, portfolio, automation, campaign, insight, workspace, seed, reducer, hydration, and persistence models.
Automation rules and execution
whatsapp/lib/automation-engine.ts, whatsapp/hooks/use-automation-scheduler.ts, whatsapp/components/whatsapp/automation-dialog.tsx, whatsapp/components/whatsapp/automations-tab.tsx, whatsapp/components/whatsapp/views/agenda-view.tsx
Adds trigger evaluation, pending actions, approval controls, rule editing, publishing, scheduling, and message execution.
Local and model-generated insights
whatsapp/lib/insights.ts, whatsapp/lib/insights-local.ts, whatsapp/app/api/insights/route.ts, whatsapp/components/whatsapp/ai-tab.tsx
Adds deterministic analysis, structured Anthropic responses, cached results, fallback handling, insight rendering, and composer suggestions.
Contact CRM panel and profile management
whatsapp/components/whatsapp/crm-panel.tsx, profile-tab.tsx, portfolio-tab.tsx, tag-combobox.tsx, currency-input.tsx, panel-section.tsx
Adds responsive CRM panels and editable contact, investor, meeting, notes, tags, and portfolio workflows.
Advisory workspaces and navigation
whatsapp/components/whatsapp/whatsapp-app.tsx, nav-rail.tsx, views/*
Adds funnel, portfolio, agenda, automation, and campaign workspaces with navigation, workspace layouts, filters, bulk actions, and Escape navigation.
Shared UI and conversation behavior
whatsapp/components/ui/*, conversation.tsx, chat-list.tsx, contact-panel.tsx, message-bubble.tsx, message-scroller.tsx
Adds reusable UI primitives and updates hydration placeholders, chat ordering, CRM panel presentation, transcript anchoring, and message styling.
End-to-end validation and project guidance
whatsapp/e2e/*, whatsapp/CLAUDE.md, whatsapp/AGENTS.md, whatsapp/.gitignore, README.md
Adds coverage for CRM, hydration, persistence, workspaces, automation, campaigns, and AI suggestions. Updates project guidance and ignored logs.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant CRMPanel
  participant Store
  participant AutomationEngine
  participant AnthropicAPI
  Operator->>CRMPanel: edit CRM data or request insights
  CRMPanel->>Store: dispatch CRM or insight action
  Store->>CRMPanel: return persisted state
  CRMPanel->>AnthropicAPI: request structured insights
  AnthropicAPI->>CRMPanel: return insight sections and suggestions
  AutomationEngine->>Store: create and update pending actions
  Store->>CRMPanel: expose agenda and automation state
Loading

Possibly related PRs

  • matheusfrainer/personal#2 — Updates and extends the same WhatsApp project files, components, persistence, navigation, and end-to-end tests.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed O título descreve de forma clara e específica a principal mudança: a adição de uma camada de CRM ao clone do WhatsApp para assessores.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/crm-assessoria

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
whatsapp/components/whatsapp/contact-panel.tsx (1)

136-150: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Render a thumbnail for videos in the media grid.

video messages include url and duration, but no poster/thumbnail field. Since the grid maps every media item and currently returns null for videos, add a poster/thumbnail field to VideoMessage and render it for video entries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/contact-panel.tsx` around lines 136 - 150,
Extend the VideoMessage model with a poster/thumbnail field, then update the
media grid mapping to render video entries using that field instead of returning
null. Preserve the existing image rendering and ensure both media types remain
keyed by m.id.

Source: Learnings

whatsapp/lib/store.tsx (1)

502-516: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Also drop pending and insights entries for the deleted chat.

DELETE_CHAT prunes calls, communities, blocked, and selectedId. It does not prune state.pending or state.insights. Both are keyed by chat id.

Two dangling references remain after a delete:

  • A PendingAction whose chatId no longer resolves stays in the review queue. The Agenda view then lists an action for a contact that does not exist, and approving it dispatches against a missing chat.
  • The cached insight under the deleted id is persisted forever. If a new chat later reuses that id, fingerprint(chat) guards the content, but the entry never expires on its own.

DELETE_AUTOMATION at Line 587 already prunes pending for the same reason, so the pattern is established.

🐛 Proposed fix
     case "DELETE_CHAT":
       return {
         ...state,
         chats: state.chats.filter((c) => c.id !== action.chatId),
         // Drop references that would otherwise dangle: a call row pointing at
         // a deleted chat silently does nothing when clicked.
         calls: state.calls.filter((c) => c.chatId !== action.chatId),
         communities: state.communities.map((com) => ({
           ...com,
           groupIds: com.groupIds.filter((id) => id !== action.chatId),
         })),
         blocked: state.blocked.filter((id) => id !== action.chatId),
+        // An approved action for a chat that no longer exists would sit in the
+        // Agenda queue and dispatch against nothing when sent.
+        pending: state.pending.filter((p) => p.chatId !== action.chatId),
+        insights: Object.fromEntries(
+          Object.entries(state.insights).filter(
+            ([chatId]) => chatId !== action.chatId
+          )
+        ),
         selectedId:
           state.selectedId === action.chatId ? null : state.selectedId,
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/lib/store.tsx` around lines 502 - 516, Update the DELETE_CHAT
reducer case to remove entries from state.pending and state.insights keyed by
action.chatId, alongside the existing calls, communities, blocked, and
selectedId cleanup. Preserve all unrelated pending actions and insight entries,
following the pruning pattern used by DELETE_AUTOMATION.
🟡 Minor comments (12)
whatsapp/components/whatsapp/contact-panel.tsx-69-70 (1)

69-70: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Translate the Dialog close control.

This new DialogContent use enables its default close button. The shared implementation exposes the accessible text "Close". Change that shared text to "Fechar".

As per coding guidelines, UI strings and aria-label values must be in Brazilian Portuguese.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/contact-panel.tsx` around lines 69 - 70, Update
the shared Dialog close control’s accessible text from “Close” to “Fechar”,
including its aria-label, while preserving the existing close behavior and
DialogContent usage.

Source: Coding guidelines

whatsapp/components/ui/checkbox.tsx-18-18 (1)

18-18: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Map Radix states to the actual data attributes.

whatsapp/app/globals.css defines only dark; data-checked, data-open, data-closed, data-horizontal, data-vertical, and data-active map to missing/literal attributes in Tailwind v4. Define the @custom-variant shorthands or rename the class variants to data-[state=checked], data-[state=open], data-[state=closed], data-[state=active], and data-orientation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/ui/checkbox.tsx` at line 18, Map Radix state variants to
their actual attributes in whatsapp/components/ui/checkbox.tsx:18-18,
whatsapp/components/ui/select.tsx:83-83, whatsapp/components/ui/tabs.tsx:19-19,
and whatsapp/components/ui/tabs.tsx:67-69 by defining the corresponding
`@custom-variant` shorthands in whatsapp/app/globals.css or replacing the literal
variants with data-[state=checked], data-[state=open], data-[state=closed],
data-[state=active], and data-orientation selectors; ensure all affected
Checkbox, Select, and Tabs styling targets the emitted Radix attributes.
whatsapp/app/api/insights/route.ts-24-75 (1)

24-75: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Enforce the five-section response contract.

SCHEMA.sections permits empty arrays, extra sections, and duplicate section IDs. The type assertion after JSON.parse() adds no runtime validation. A schema-valid model response can therefore omit or duplicate panel sections.

Set minItems and maxItems to five. Validate the expected unique IDs and order before returning data.

Also applies to: 216-223

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/app/api/insights/route.ts` around lines 24 - 75, Update
SCHEMA.sections to require exactly five entries using minItems and maxItems set
to 5, then add runtime validation after JSON.parse() that confirms the section
IDs are exactly ["comunicacao", "decisao", "ips", "oportunidade", "acoes"] in
that order and rejects invalid responses before returning data.
whatsapp/hooks/use-automation-scheduler.ts-75-77 (1)

75-77: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Corrija a concordância verbal no plural do toast.

Com due.length > 1, o texto renderiza "2 mensagens aprovadas saiu agora". O verbo permanece no singular.

✏️ Correção sugerida
-      toast("Automações enviadas", {
-        description: `${due.length} mensagem${due.length > 1 ? "s" : ""} aprovada${due.length > 1 ? "s" : ""} saiu agora.`,
-      })
+      const plural = due.length > 1
+      toast("Automações enviadas", {
+        description: `${due.length} mensagem${plural ? "s" : ""} aprovada${plural ? "s" : ""} ${plural ? "saíram" : "saiu"} agora.`,
+      })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/hooks/use-automation-scheduler.ts` around lines 75 - 77, Atualize o
toast na função que processa as automações agendadas para ajustar a concordância
verbal conforme due.length: use o verbo no plural quando houver mais de uma
mensagem e mantenha o singular para uma mensagem.

Source: Coding guidelines

whatsapp/components/whatsapp/views/agenda-view.tsx-205-207 (1)

205-207: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The "Resolvidas hoje" section is not restricted to today.

done selects every sent or declined action, regardless of its day field. Actions resolved on earlier days appear under a heading that states today. PendingAction carries day, so the filter can use it.

+  const today = new Date()
+  const dayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
   const done = state.pending.filter(
-    (p) => p.status === "sent" || p.status === "declined"
+    (p) => (p.status === "sent" || p.status === "declined") && p.day === dayKey
   )

Alternatively, change the heading to "Resolvidas".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/agenda-view.tsx` around lines 205 - 207,
Update the done filter in the agenda view to include only actions whose status
is sent or declined and whose day matches today, using PendingAction.day and the
view’s existing today/date value. Preserve the current resolved-actions behavior
for today while excluding actions from earlier days.
whatsapp/components/whatsapp/nav-rail.tsx-69-71 (1)

69-71: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

O aria-label do badge de conversas anuncia "pendentes".

railButton gera o mesmo sufixo para os dois tipos de badge. Em "Conversas" o número representa mensagens não lidas, não itens pendentes. O leitor de tela anuncia "Conversas, 3 pendentes".

Passe o texto do badge junto com o valor.

♿ Correção sugerida
-  function railButton(entry: Entry, badge?: number) {
+  function railButton(entry: Entry, badge?: number, badgeNoun = "pendentes") {
     const active = state.view === entry.view
     return (
       <Tooltip key={entry.view}>
         <TooltipTrigger asChild>
           <Button
             variant="ghost"
             size="icon-lg"
             aria-label={
-              badge ? `${entry.label}, ${badge} pendentes` : entry.label
+              badge ? `${entry.label}, ${badge} ${badgeNoun}` : entry.label
             }
       {PRIMARY.map((entry) =>
-        railButton(entry, entry.view === "chats" ? unreadTotal : undefined)
+        railButton(
+          entry,
+          entry.view === "chats" ? unreadTotal : undefined,
+          "não lidas"
+        )
       )}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/nav-rail.tsx` around lines 69 - 71, Atualize o
uso de `railButton` e o `aria-label` para receber também o texto do badge junto
com seu valor, evitando que o sufixo fixo “pendentes” seja aplicado a Conversas.
Preserve a descrição “pendentes” para badges de itens pendentes e use a
descrição apropriada de mensagens não lidas para o badge de Conversas.

Source: Coding guidelines

whatsapp/e2e/crm.spec.ts-126-144 (1)

126-144: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Persist an actual CRM edit before reloading.

This test only expands the "Cliente" section. Both assertions read unchanged seeded data. It does not validate that a CRM mutation reaches localStorage.

Edit an input such as "Empresa" before the reload. Then assert the changed value after reopening the panel.

Proposed test change
   const panel = page.getByRole("tabpanel")
   await panel.getByRole("button", { name: "Cliente", exact: true }).click()
   await expect(panel.getByText(/Cliente desde/)).toBeVisible()
+  const company = panel.getByRole("textbox", { name: "Empresa" })
+  await company.fill("Empresa persistida")
 
   // Give the store's debounced write a chance to land before reloading.
   await page.waitForTimeout(600)
   await page.reload()
   await openChat(page, "Ana Beatriz")
   await openClientPanel(page)
 
   await expect(
-    page.getByRole("tabpanel").getByText(/Cliente desde/)
-  ).toBeVisible()
+    page.getByRole("tabpanel").getByRole("textbox", { name: "Empresa" })
+  ).toHaveValue("Empresa persistida")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/e2e/crm.spec.ts` around lines 126 - 144, Update the test “edits CRM
data and keeps it across a reload” to modify a CRM input, such as “Empresa,”
after opening the “Cliente” section, then assert the edited value before and
after page.reload(). Keep the existing panel reopening flow and verify the
persisted changed value rather than only the unchanged “Cliente desde” text.
whatsapp/e2e/crm.spec.ts-39-102 (1)

39-102: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Freeze the clock for this deterministic ordering test.

sendMessage() reads Intl.DateTimeFormat(...).format(new Date()), so running the test at 23:59 can make the sent message have the same time as "última da noite". Clock ties are not tied to the existing ordering, so sent < night can fail. Set the application clock before page.goto() to a time between 00:01 and 23:58.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/e2e/crm.spec.ts` around lines 39 - 102, Freeze the application clock
in the ordering test before page.goto(), using the test framework’s clock
control to set a time strictly between 00:01 and 23:58. Keep the existing seeded
messages and ordering assertions unchanged, ensuring sendMessage() produces a
deterministic time distinct from both seeded messages.

Source: Coding guidelines

whatsapp/components/whatsapp/conversation.tsx-410-438 (1)

410-438: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the narrow-layout branch toggle, so aria-pressed stays truthful.

The button declares aria-pressed at Line 419, which announces it as a toggle button. On narrow layouts the handler at Line 427 only calls setCrmSheetOpen(true). Activating the button a second time does not change the state, so the announced pressed state can never be reversed by the control that announces it.

The sheet does close through its own dismiss control and Escape, and onOpenChange writes back to crmSheetOpen. The gap is limited to the button itself. Make the narrow branch toggle to match the wide branch.

♿ Proposed fix
                   onClick={() =>
                     wide
                       ? dispatch({
                           type: "SET_PREFERENCE",
                           key: "crmPanel",
                           value: !crmPanelOpen,
                         })
-                      : setCrmSheetOpen(true)
+                      : setCrmSheetOpen((open) => !open)
                   }
                 >
                   <Icon icon={PipelineIcon} />
                 </Button>
               </TooltipTrigger>
               <TooltipContent>
-                {wide && crmPanelOpen
+                {(wide ? crmPanelOpen : crmSheetOpen)
                   ? "Ocultar painel do cliente"
                   : "Painel do cliente"}
               </TooltipContent>

As per coding guidelines: "UI strings, aria-label values, and commit messages must be in Brazilian Portuguese" — the proposed tooltip text stays in Brazilian Portuguese.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/conversation.tsx` around lines 410 - 438, Update
the narrow-layout branch of the Button onClick handler to toggle crmSheetOpen
using its current state, while preserving the wide-layout preference toggle and
existing Portuguese labels. Ensure repeated activations alternately open and
close the sheet so aria-pressed remains truthful.

Source: Coding guidelines

whatsapp/lib/storage.ts-26-29 (1)

26-29: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Prune reviewed actions so pending does not grow without bound.

sanitize() keeps every action whose status is not "pending". Actions with status "approved", "declined", and "sent" are therefore persisted forever. Each day adds new rows, and the array is never pruned. Two effects follow: the persisted payload grows until the localStorage quota is reached, and the dedup set built in SET_PENDING in whatsapp/lib/store.tsx grows with it.

Keep only a recent window of reviewed actions. The dedup key already carries day, so a date cutoff is enough.

🧹 Proposed retention window
+// Reviewed actions only matter for the dedup check, which is keyed by day.
+// Anything older than a week can never suppress a fresh proposal, so drop it
+// instead of letting the queue grow until the quota fails.
+const RETAIN_DAYS = 7
+
 function sanitize(state: PersistedState): PersistedState {
+  const cutoff = new Date(Date.now() - RETAIN_DAYS * 86_400_000)
+    .toISOString()
+    .slice(0, 10)
   return {
     ...state,
     chats: state.chats.map((chat) => {
       const copy = { ...chat }
       delete copy.typing
       return copy
     }),
     // Approved-but-unsent actions are re-evaluated on load; a "sent" flag is
     // history and stays. Nothing pending survives a reload as pending, so a
     // rule whose trigger no longer holds doesn't fire from a stale queue.
-    pending: (state.pending ?? []).filter((p) => p.status !== "pending"),
+    pending: (state.pending ?? []).filter(
+      (p) => p.status !== "pending" && p.day >= cutoff
+    ),
   }
 }

As per coding guidelines: "sanitize() must run on both read and write".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/lib/storage.ts` around lines 26 - 29, Update sanitize() to retain
only reviewed actions from a recent date window, using each action’s dedup-key
day, while continuing to exclude status "pending" and preserve current handling
for current-window actions. Ensure sanitize() is applied on both storage reads
and writes so stale reviewed entries are pruned before persistence and before
rebuilding the SET_PENDING dedup set.

Source: Coding guidelines

whatsapp/components/whatsapp/crm-panel.tsx-49-68 (1)

49-68: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

SavedHint mostra "salvo" ao trocar de conversa.

O componente compara a assinatura de chat.crm entre renders. Quando o usuário troca de contato, o prop chat muda e a assinatura muda junto. O componente então marca saved = true sem que nenhuma edição tenha ocorrido. PanelTabs é reiniciado por key={chat.id}, mas SavedHint não.

Inclua o chat.id na comparação para reiniciar sem sinalizar salvamento.

🐛 Correção proposta
 function SavedHint({ chat }: { chat: Chat }) {
   const [saved, setSaved] = React.useState(false)
   const signature = JSON.stringify(chat.crm ?? {})
   const [seen, setSeen] = React.useState(signature)
+  const [seenChat, setSeenChat] = React.useState(chat.id)
 
   // Adjusting state during render, not in an effect: React re-runs the render
   // before painting, where an effect would paint the stale value first and
   // cascade a second render on top of it.
-  if (seen !== signature) {
+  if (seenChat !== chat.id) {
+    // A different contact is not an edit, so reset without announcing a save.
+    setSeenChat(chat.id)
+    setSeen(signature)
+    setSaved(false)
+  } else if (seen !== signature) {
     setSeen(signature)
     setSaved(true)
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/crm-panel.tsx` around lines 49 - 68, Update
SavedHint’s render-time change detection to track both chat.id and the chat.crm
signature, so switching conversations resets the tracking state without setting
saved to true. Preserve the existing behavior of setting saved for CRM edits
within the same chat and keep the timer logic unchanged.
whatsapp/components/whatsapp/profile-tab.tsx-246-260 (1)

246-260: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Trocar de conversa dentro da janela de 400 ms descarta as observações digitadas.

A sincronização em render redefine notes quando chat.id muda. A limpeza do efeito cancela o temporizador pendente do contato anterior. O texto digitado nos últimos 400 ms nunca chega ao store.

Grave o rascunho pendente na limpeza, usando o chatId correto.

🐛 Correção proposta
   React.useEffect(() => {
     const handle = window.setTimeout(() => {
       if ((crm.notes ?? "") !== notes) set({ notes: notes || undefined })
     }, 400)
-    return () => window.clearTimeout(handle)
+    return () => {
+      window.clearTimeout(handle)
+      // Sair do contato antes do debounce não pode perder o que já foi digitado.
+      if ((crm.notes ?? "") !== notes) {
+        dispatch({
+          type: "SET_CRM",
+          chatId: chat.id,
+          patch: { notes: notes || undefined },
+        })
+      }
+    }
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [notes, chat.id])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/profile-tab.tsx` around lines 246 - 260, Update
the notes debounce effect around notes, chat.id, and set so its cleanup persists
any pending draft before the timer is cancelled, using the chatId captured for
that effect rather than the newly selected chat. Preserve the existing 400 ms
debounced save while ensuring switching conversations does not discard recently
typed notes.
🧹 Nitpick comments (19)
whatsapp/lib/insights.ts (2)

9-14: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make interface comments explain constraints.

These comments restate field names and types. Remove them or explain why each field is optional or required.

As per coding guidelines, “Comments must explain why, not what, including comments on interface fields.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/lib/insights.ts` around lines 9 - 14, Update the interface field
comments for label, value, and detail to explain their constraints or purpose
rather than restating their names and types; clarify why label and value are
required and why detail is optional, or remove comments that do not add this
context.

Source: Coding guidelines


1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the @/* alias for project imports.

  • whatsapp/lib/insights.ts#L1-L1: replace ./types with @/lib/types.
  • whatsapp/lib/insights-local.ts#L1-L13: replace relative project imports with @/lib/... aliases.
  • whatsapp/components/whatsapp/ai-tab.tsx#L14-L24: replace relative component imports with @/components/whatsapp/... aliases.

As per coding guidelines, “Use the @/* import alias.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/lib/insights.ts` at line 1, Replace the relative project imports
with the configured `@/`* aliases: update whatsapp/lib/insights.ts lines 1-1 to
use `@/lib/types`, whatsapp/lib/insights-local.ts lines 1-13 to use `@/lib/`...
paths, and whatsapp/components/whatsapp/ai-tab.tsx lines 14-24 to use
`@/components/whatsapp/`... paths.

Source: Coding guidelines

whatsapp/components/whatsapp/whatsapp-app.tsx (1)

88-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant !workspace term.

state.view === "chats" already excludes every workspace view, because isWorkspaceView("chats") is false. The extra term adds no condition.

-  const conversationOpen =
-    Boolean(selectedChat) && state.view === "chats" && !workspace
+  const conversationOpen = Boolean(selectedChat) && state.view === "chats"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/whatsapp-app.tsx` around lines 88 - 91, Remove
the redundant !workspace condition from the conversationOpen expression, keeping
the existing selectedChat and state.view === "chats" checks unchanged.
whatsapp/components/whatsapp/automation-dialog.tsx (1)

98-99: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Require a keyword when the trigger is palavra-chave.

The engine returns null immediately when config.keyword is empty. The user can save a rule that can never fire. Extend valid to cover this case.

-  const valid = draft.name.trim().length > 0 && draft.message.trim().length > 0
+  const valid =
+    draft.name.trim().length > 0 &&
+    draft.message.trim().length > 0 &&
+    (draft.trigger !== "palavra-chave" ||
+      (draft.config.keyword ?? "").trim().length > 0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/automation-dialog.tsx` around lines 98 - 99,
Update the validation expression in the automation dialog around dayLabel and
valid to require a non-empty trimmed keyword when draft.trigger is
`palavra-chave`, while preserving the existing name and message requirements for
all triggers.
whatsapp/hooks/use-automation-scheduler.ts (1)

54-63: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer a typed message literal over the as Message cast.

The cast disables checking of type, status, and required fields against the Message union. A typed annotation keeps the contract enforced if the union changes.

-        const message = {
+        const message: Message = {
           type: "text",
           id: nextId(),
           fromMe: true,
           text: action.message,
           time: nowTime(now),
           status: "sent",
-        } as Message
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/hooks/use-automation-scheduler.ts` around lines 54 - 63, Replace the
`as Message` cast on the message literal inside the due-action loop with an
explicit `Message` type annotation. Preserve the existing fields and dispatch
behavior while ensuring the literal is checked against the `Message` union and
required fields remain enforced.
whatsapp/components/whatsapp/views/carteiras-view.tsx (2)

249-251: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Drop cn around the description string.

cn merges class names through clsx and tailwind-merge. Here it receives a single interpolated description and returns it unchanged. Pass the template literal directly.

-      description={cn(
-        `${formatCurrency(totals.auc)} sob gestão · ${formatCurrency(totals.pipe)} a captar`
-      )}
+      description={`${formatCurrency(totals.auc)} sob gestão · ${formatCurrency(totals.pipe)} a captar`}

The cn import is then unused in this file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/carteiras-view.tsx` around lines 249 -
251, Remove the unnecessary cn wrapper from the description prop in the relevant
carteira view, passing the interpolated string directly. Then remove the
now-unused cn import from the file.

208-231: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Hoist the max computation out of the map.

max scans every lead once per stage inside the loop, so the block is O(stages² × leads). The value does not depend on the current stage. Compute the per-stage counts once, then derive max and render.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/carteiras-view.tsx` around lines 208 -
231, Refactor the stage rendering around stageOrder.map so per-stage lead counts
are computed once before the map. Derive the shared max from those counts, then
reuse each stage’s count and max when rendering BarRow, avoiding repeated
leads.filter scans inside the loop.
whatsapp/components/whatsapp/views/automacoes-view.tsx (2)

124-132: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Confirm the deletion of a shared library rule.

DELETE_AUTOMATION removes a rule that applies to every contact. The menu item runs immediately, and there is no undo path in the store. Add a confirmation step, or offer an undo action in a toast.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/automacoes-view.tsx` around lines 124 -
132, Update the DELETE_AUTOMATION action in the DropdownMenuItem to require
confirmation before removing the shared rule, or provide an equivalent undo
action through a toast; preserve the existing deletion dispatch only after
confirmation or when the undo flow is finalized.

150-153: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

locals includes groups and archived chats.

RuleRow at Line 47 counts only the book, which excludes groups and archived chats. This list uses state.chats unfiltered, so a rule on an archived contact appears under "Só de uma pessoa". Use the same filter for both.

-  const locals = state.chats.flatMap((chat) =>
-    (chat.automations ?? []).map((rule) => ({ chat, rule }))
-  )
+  const locals = state.chats
+    .filter((c) => !c.isGroup && !c.archived)
+    .flatMap((chat) => (chat.automations ?? []).map((rule) => ({ chat, rule })))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/automacoes-view.tsx` around lines 150 -
153, Update the locals computation in the automações view to filter state.chats
using the same criteria as RuleRow before flattening automations, excluding
groups and archived chats. Keep the existing { chat, rule } mapping and ensure
both the list and RuleRow count only eligible contacts.
whatsapp/components/whatsapp/views/workspace-shell.tsx (1)

14-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The tabs slot is unused; the three workspaces duplicate its markup.

FunilView, CarteirasView, and AutomacoesView each wrap their TabsList in <div className="border-b border-border px-4 py-2"> inside children, which repeats exactly what Lines 38-40 provide. Either pass the tab bar through the tabs prop in those views, or remove the prop.

Note that the Tabs root must stay above both the tab bar and the content, so using the slot requires the shell to sit inside Tabs in each view.

Also applies to: 38-40

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/workspace-shell.tsx` around lines 14 - 15,
Remove the unused tabs prop from the workspace shell and update FunilView,
CarteirasView, and AutomacoesView accordingly, or pass each view’s tab bar
through that prop while keeping the Tabs root above both the tab bar and
content. Eliminate the duplicated border wrapper markup and preserve the
existing tab layout.
whatsapp/components/whatsapp/views/campanhas-view.tsx (2)

37-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated nowTime helper in two outbound message paths. Both files define the same pt-BR HH:MM formatter to stamp messages the application sends. The shared root cause is a missing helper in a common module, for example lib/crm.ts next to formatDate, or lib/data.ts next to the other message helpers.

  • whatsapp/components/whatsapp/views/campanhas-view.tsx#L37-L42: remove the local nowTime and import the shared helper; pass the Date explicitly so the campaign stamps every message with one instant.
  • whatsapp/hooks/use-automation-scheduler.ts#L14-L19: remove the local nowTime and import the same shared helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/campanhas-view.tsx` around lines 37 - 42,
Centralize the duplicated pt-BR time formatter in a shared module near the
existing message/date helpers. In
whatsapp/components/whatsapp/views/campanhas-view.tsx lines 37-42, remove the
local nowTime, import the shared helper, and pass one explicit Date so all
campaign messages use the same instant; in
whatsapp/hooks/use-automation-scheduler.ts lines 14-19, remove the local nowTime
and import the same helper.

291-298: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Confirm the broadcast before writing into the conversations.

The button sends a message to every recipient in one click. The writes land in real conversations and there is no undo. Add a confirmation dialog that repeats the recipient count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/campanhas-view.tsx` around lines 291 -
298, Update the send action around the Button and send handler to require
explicit user confirmation before broadcasting to recipients. Add a confirmation
dialog that clearly repeats recipients.length, invoke send only after
confirmation, and preserve the existing disabled conditions and button label.
whatsapp/components/whatsapp/views/agenda-view.tsx (1)

42-48: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The edit draft does not follow later changes to the action.

draft is seeded once from action.message. Reavaliar at Line 209 replaces the pending queue, and the scheduler replaces it every 60 seconds. If the card remounts with a different message while the editor is closed, the stored draft is correct; if the same card instance receives a new action.message, the editor still shows the old text and saving overwrites the new message.

Reset the draft when the editor opens.

-  const [editing, setEditing] = React.useState(false)
-  const [draft, setDraft] = React.useState(action.message)
+  const [editing, setEditing] = React.useState(false)
+  const [draft, setDraft] = React.useState(action.message)
+  // Reopening the editor must show the current message, not a stale draft.
+  function toggleEditing() {
+    setDraft(action.message)
+    setEditing((v) => !v)
+  }

Then call toggleEditing at Line 74.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/agenda-view.tsx` around lines 42 - 48,
Update ActionCard’s editing toggle so opening the editor first resets draft from
the current action.message, while closing preserves existing behavior. Ensure
the edit control invokes toggleEditing rather than changing editing state
directly, so each editor opening displays the latest action message.
whatsapp/e2e/hydration.spec.ts (1)

53-59: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Wait for the persisted condition instead of a fixed delay.

A slow runner can exceed 600 ms before the storage write completes. Retry the expected key set with expect(...).toPass().

As per coding guidelines, “Make end-to-end tests deterministic.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/e2e/hydration.spec.ts` around lines 53 - 59, Replace the fixed
waitForTimeout delay in the hydration test with an expect(...).toPass() retry
around the localStorage key-set assertion after page.goto. Keep reading
“whatsapp-shadcn:state:v3” via page.evaluate and assert the expected sorted keys
so the test waits for persistence to complete deterministically.

Source: Coding guidelines

whatsapp/components/whatsapp/crm-panel.tsx (1)

70-80: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

O texto "salvo" permanece na árvore de acessibilidade quando oculto.

opacity-0 esconde o texto visualmente, mas o mantém acessível a leitores de tela. Com aria-live="polite", a região deve conter o texto apenas enquanto a mensagem for válida. Renderize o texto de forma condicional dentro da região viva.

♻️ Ajuste sugerido
     <span
       aria-live="polite"
-      className={cn(
-        "text-[0.625rem] text-muted-foreground transition-opacity",
-        saved ? "opacity-100" : "opacity-0"
-      )}
-    >
-      salvo
-    </span>
+      className="text-[0.625rem] text-muted-foreground transition-opacity"
+    >
+      {saved ? "salvo" : null}
+    </span>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/crm-panel.tsx` around lines 70 - 80, Atualize o
elemento com aria-live no fluxo de renderização do painel para renderizar o
texto “salvo” somente quando saved for verdadeiro, em vez de mantê-lo na árvore
com opacity-0. Preserve a região viva e os estilos existentes para o estado em
que a mensagem é exibida.
whatsapp/components/whatsapp/currency-input.tsx (1)

44-49: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

O rascunho inicial usa ponto decimal.

String(value) produz "1250.5" enquanto o campo aceita e exibe vírgula decimal. O usuário vê um formato ao focar e outro ao ler. Formate o rascunho com vírgula para manter o padrão pt-BR.

♻️ Ajuste sugerido
       onFocus={() => {
-        setDraft(value != null ? String(value) : "")
+        setDraft(value != null ? String(value).replace(".", ",") : "")
         setEditing(true)
       }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/currency-input.tsx` around lines 44 - 49,
Atualize o callback onFocus do componente de entrada para inicializar draft
usando a formatação pt-BR já aplicada à exibição, substituindo o String(value)
bruto por uma conversão que use vírgula como separador decimal. Preserve o
comportamento para valores nulos e o fluxo existente de setEditing.
whatsapp/components/whatsapp/profile-tab.tsx (1)

594-608: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

O resumo da reunião grava no store a cada tecla.

O comentário na linha 246 declara que digitar não deve reescrever o chat a cada caractere. Este Textarea faz exatamente isso: cada onChange reconstrói o array meetings e despacha SET_CRM, o que também persiste em localStorage a cada tecla. O título da mesma reunião usa Field, que grava no blur. Aplique o mesmo padrão ao resumo, com estado local e commit no blur ou com debounce.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/profile-tab.tsx` around lines 594 - 608,
Atualize o Textarea de resumo da reunião no componente correspondente para usar
estado local durante a digitação e persistir a alteração apenas no blur,
seguindo o padrão do Field usado para o título; evite reconstruir meetings e
despachar SET_CRM a cada onChange, mantendo o valor final sincronizado com
meeting.summary.
whatsapp/lib/crm.ts (2)

177-187: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Corrija o comentário de daysUntilBirthday.

A função nunca retorna valores negativos. Quando a data já passou no ano corrente, ela avança para o próximo ano. O comentário diz "Days since the birthday", o que não descreve o retorno.

♻️ Ajuste sugerido
-/** Days since the birthday, or until the next one. */
+/** Days until the next birthday; never negative. */
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/lib/crm.ts` around lines 177 - 187, Atualize o comentário de
`daysUntilBirthday` para descrever que a função retorna os dias até o próximo
aniversário, sem mencionar dias desde o aniversário. Não altere a lógica da
função.

87-115: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

crmOf devolve o mesmo objeto emptyCrm para todo chat sem CRM.

Os arrays tags, positions e meetings são compartilhados entre todos os contatos sem dados. Hoje os consumidores usam apenas leitura e spread, então não há defeito. Uma mutação futura em qualquer tela afetaria todos os contatos. Retornar uma cópia elimina esse risco.

🛡️ Correção defensiva sugerida
 export function crmOf(chat: Chat): CrmData {
   const crm = chat.crm
-  if (!crm) return emptyCrm
+  if (!crm) {
+    return { ...emptyCrm, tags: [], positions: [], meetings: [], profile: { ...emptyProfile } }
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/lib/crm.ts` around lines 87 - 115, Update crmOf so chats without CRM
return a fresh CrmData object instead of the shared emptyCrm reference,
including newly allocated tags, positions, and meetings arrays and an
independent profile object. Preserve the existing emptyCrm values while
preventing mutations from being shared across contacts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d76785db-6641-456d-b182-8b02f3c64415

📥 Commits

Reviewing files that changed from the base of the PR and between 012c120 and ead392b.

⛔ Files ignored due to path filters (1)
  • whatsapp/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (53)
  • README.md
  • whatsapp/.gitignore
  • whatsapp/AGENTS.md
  • whatsapp/CLAUDE.md
  • whatsapp/README.md
  • whatsapp/app/api/insights/route.ts
  • whatsapp/components/ui/bubble.tsx
  • whatsapp/components/ui/checkbox.tsx
  • whatsapp/components/ui/label.tsx
  • whatsapp/components/ui/message-scroller.tsx
  • whatsapp/components/ui/progress.tsx
  • whatsapp/components/ui/select.tsx
  • whatsapp/components/ui/table.tsx
  • whatsapp/components/ui/tabs.tsx
  • whatsapp/components/whatsapp/ai-tab.tsx
  • whatsapp/components/whatsapp/automation-dialog.tsx
  • whatsapp/components/whatsapp/automations-tab.tsx
  • whatsapp/components/whatsapp/chat-composer.tsx
  • whatsapp/components/whatsapp/chat-list.tsx
  • whatsapp/components/whatsapp/contact-panel.tsx
  • whatsapp/components/whatsapp/conversation.tsx
  • whatsapp/components/whatsapp/crm-panel.tsx
  • whatsapp/components/whatsapp/currency-input.tsx
  • whatsapp/components/whatsapp/icons.ts
  • whatsapp/components/whatsapp/message-bubble.tsx
  • whatsapp/components/whatsapp/nav-rail.tsx
  • whatsapp/components/whatsapp/panel-section.tsx
  • whatsapp/components/whatsapp/portfolio-tab.tsx
  • whatsapp/components/whatsapp/profile-tab.tsx
  • whatsapp/components/whatsapp/tag-combobox.tsx
  • whatsapp/components/whatsapp/views/agenda-view.tsx
  • whatsapp/components/whatsapp/views/automacoes-view.tsx
  • whatsapp/components/whatsapp/views/campanhas-view.tsx
  • whatsapp/components/whatsapp/views/carteiras-view.tsx
  • whatsapp/components/whatsapp/views/funil-view.tsx
  • whatsapp/components/whatsapp/views/workspace-shell.tsx
  • whatsapp/components/whatsapp/whatsapp-app.tsx
  • whatsapp/e2e/app.spec.ts
  • whatsapp/e2e/crm.spec.ts
  • whatsapp/e2e/hydration.spec.ts
  • whatsapp/e2e/workspaces.spec.ts
  • whatsapp/hooks/use-automation-scheduler.ts
  • whatsapp/hooks/use-media-query.ts
  • whatsapp/lib/automation-engine.ts
  • whatsapp/lib/crm.ts
  • whatsapp/lib/data.ts
  • whatsapp/lib/insights-local.ts
  • whatsapp/lib/insights.ts
  • whatsapp/lib/portfolio.ts
  • whatsapp/lib/storage.ts
  • whatsapp/lib/store.tsx
  • whatsapp/lib/types.ts
  • whatsapp/package.json
💤 Files with no reviewable changes (1)
  • whatsapp/README.md

Comment on lines +168 to +172
let chat: Chat
try {
const body = (await request.json()) as { chat?: Chat }
if (!body.chat?.id) throw new Error("chat ausente")
chat = body.chat

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Protect the model endpoint from direct cost abuse.

The route has no caller authentication, authorization, rate limit, or transcript-size limit. Any client that can reach the deployment can submit a valid Chat payload and consume Anthropic capacity, including up to 8,000 output tokens per request.

Require an authorized caller and apply per-caller rate, budget, and request-size limits before client.messages.create().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/app/api/insights/route.ts` around lines 168 - 172, Harden the
insights route before its Anthropic call: require and validate an authorized
caller, enforce per-caller rate and budget limits, and reject oversized chat
transcripts or requests before reaching client.messages.create(). Apply these
checks to the request flow around the chat parsing and preserve the existing
behavior for authorized, within-limit requests.

Comment on lines +195 to +207
<Input
id="rule-days"
type="number"
min={1}
value={draft.config.days ?? ""}
onChange={(e) =>
setDraft({
...draft,
config: { ...draft.config, days: Number(e.target.value) },
})
}
className="h-8 text-xs tabular-nums"
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clearing the day field stores NaN and disables the rule silently.

Number("") returns NaN. The engine reads rule.config.days ?? 30, and ?? does not replace NaN. Every comparison against NaN is false, so the saved rule never fires and the user gets no feedback.

Store undefined when the field is empty, so the engine default applies.

🐛 Proposed fix
                   onChange={(e) =>
                     setDraft({
                       ...draft,
-                      config: { ...draft.config, days: Number(e.target.value) },
+                      config: {
+                        ...draft.config,
+                        days: e.target.value === ""
+                          ? undefined
+                          : Number(e.target.value),
+                      },
                     })
                   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Input
id="rule-days"
type="number"
min={1}
value={draft.config.days ?? ""}
onChange={(e) =>
setDraft({
...draft,
config: { ...draft.config, days: Number(e.target.value) },
})
}
className="h-8 text-xs tabular-nums"
/>
<Input
id="rule-days"
type="number"
min={1}
value={draft.config.days ?? ""}
onChange={(e) =>
setDraft({
...draft,
config: {
...draft.config,
days:
e.target.value === ""
? undefined
: Number(e.target.value),
},
})
}
className="h-8 text-xs tabular-nums"
/>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/automation-dialog.tsx` around lines 195 - 207,
Update the days input handler in the automation dialog to store undefined when
the field is empty instead of converting an empty string to NaN; otherwise
continue storing the numeric value so the engine’s existing rule.config.days ??
30 default applies.

Comment on lines +32 to +38
function commit() {
setEditing(false)
const cleaned = draft.replace(/[^\d,.-]/g, "").replace(",", ".")
const parsed = Number(cleaned)
if (!cleaned) return onCommit(undefined)
if (Number.isFinite(parsed)) onCommit(parsed)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

O parser rejeita valores com separador de milhar pt-BR.

O usuário digita "1.250,00". O replace mantém os pontos e troca apenas a primeira vírgula, produzindo "1.250.00". Number("1.250.00") é NaN, então onCommit não é chamado e a edição é descartada sem aviso. O mesmo ocorre com "12.500".

Remova os separadores de milhar antes de converter a vírgula decimal.

🐛 Correção proposta
   function commit() {
     setEditing(false)
-    const cleaned = draft.replace(/[^\d,.-]/g, "").replace(",", ".")
-    const parsed = Number(cleaned)
-    if (!cleaned) return onCommit(undefined)
-    if (Number.isFinite(parsed)) onCommit(parsed)
+    const raw = draft.replace(/[^\d,.-]/g, "")
+    if (!raw) return onCommit(undefined)
+    // pt-BR: "." separa milhar e "," separa decimal, então o ponto sai antes
+    // de a vírgula virar ponto decimal.
+    const cleaned = raw.includes(",")
+      ? raw.replaceAll(".", "").replace(",", ".")
+      : raw
+    const parsed = Number(cleaned)
+    if (Number.isFinite(parsed)) onCommit(parsed)
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/currency-input.tsx` around lines 32 - 38,
Atualize a função commit para remover os separadores de milhar ponto antes de
converter a vírgula decimal e chamar Number. Preserve o tratamento de entrada
vazia e o commit apenas para valores finitos, garantindo que formatos como
“1.250,00” e “12.500” sejam convertidos corretamente.

Comment on lines +52 to +58
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur()
if (e.key === "Escape") {
setEditing(false)
e.currentTarget.blur()
}
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Escape grava o valor em vez de cancelar.

setEditing(false) não impede o commit. A chamada a blur() dispara onBlur={commit}, e commit ainda lê o draft editado. O valor digitado é gravado, ao contrário do cancelamento documentado no resumo do componente.

Use uma referência de cancelamento consultada por commit.

🐛 Correção proposta
   const [editing, setEditing] = React.useState(false)
   const [draft, setDraft] = React.useState("")
+  // Escape precisa vencer o blur, que dispara logo depois e leria o rascunho.
+  const cancelled = React.useRef(false)
 
   const display = value != null ? formatCurrency(value) : ""
 
   function commit() {
     setEditing(false)
+    if (cancelled.current) {
+      cancelled.current = false
+      return
+    }
         if (e.key === "Escape") {
+          cancelled.current = true
           setEditing(false)
           e.currentTarget.blur()
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/currency-input.tsx` around lines 52 - 58, Update
the Escape handling in the currency input’s onKeyDown and the commit function
used by onBlur so cancellation is tracked through a ref consulted by commit.
Mark the edit as canceled before blurring, have commit return without saving
when that ref is set, and preserve normal Enter/onBlur commits.

Comment on lines +64 to +69
const [draft, setDraft] = React.useState<Position>(EMPTY)
const [seen, setSeen] = React.useState<Position | null>(null)
if (open && seen !== position) {
setSeen(position)
setDraft(position ?? { ...EMPTY, id: nextId() })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

A primeira "Nova posição" é salva com id vazio.

seen inicia como null e position também é null ao adicionar. A condição seen !== position é falsa, então draft permanece igual a EMPTY, cujo id é "". nextId() não é chamado.

Consequências na linha 369: a primeira posição é adicionada com id: ""; uma segunda adição nas mesmas condições encontra exists === true e sobrescreve a primeira. As chaves de lista em key={position.id} também colidem.

Use um sentinela distinto de null para o estado seen.

🐛 Correção proposta
   const [draft, setDraft] = React.useState<Position>(EMPTY)
-  const [seen, setSeen] = React.useState<Position | null>(null)
-  if (open && seen !== position) {
+  // `undefined` marca "nunca inicializado", já que `null` é um valor válido
+  // de `position` (nova posição).
+  const [seen, setSeen] = React.useState<Position | null | undefined>(undefined)
+  if (open && seen !== position) {
     setSeen(position)
     setDraft(position ?? { ...EMPTY, id: nextId() })
   }
+  if (!open && seen !== undefined) setSeen(undefined)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/portfolio-tab.tsx` around lines 64 - 69, Use a
sentinel distinct from null for the initial seen state in the component’s seen
state declaration, so the open && seen !== position initialization block runs
when both seen and position are null. Preserve the existing draft initialization
and ensure the first new position receives nextId() instead of EMPTY.id.

Comment on lines +147 to +161
// Funding this month = what became "cliente" in the current month, valued
// at the assets that came under management with them.
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
.toISOString()
.slice(0, 10)
const captured = chats
.filter((c) => {
const crm = crmOf(c)
return (
crm.relationship === "cliente" &&
crm.clientSince != null &&
crm.clientSince >= monthStart
)
})
.reduce((n, c) => n + totalsOf(c).auc, 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

monthStart shifts to the previous month in negative UTC offsets.

new Date(now.getFullYear(), now.getMonth(), 1) builds local midnight. toISOString() converts to UTC before slice(0, 10). In Brazil (UTC-3), the first of the month becomes the last day of the previous month, for example 2026-07-31 instead of 2026-08-01. Clients whose clientSince is the last day of the previous month are then counted in this month's captured total.

Build the key from the local date parts.

🐛 Proposed fix
-  const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
-    .toISOString()
-    .slice(0, 10)
+  // Local parts, not toISOString(): the UTC conversion moves the first of the
+  // month to the previous day in negative offsets such as UTC-3.
+  const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Funding this month = what became "cliente" in the current month, valued
// at the assets that came under management with them.
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
.toISOString()
.slice(0, 10)
const captured = chats
.filter((c) => {
const crm = crmOf(c)
return (
crm.relationship === "cliente" &&
crm.clientSince != null &&
crm.clientSince >= monthStart
)
})
.reduce((n, c) => n + totalsOf(c).auc, 0)
// Funding this month = what became "cliente" in the current month, valued
// at the assets that came under management with them.
// Local parts, not toISOString(): the UTC conversion moves the first of the
// month to the previous day in negative offsets such as UTC-3.
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`
const captured = chats
.filter((c) => {
const crm = crmOf(c)
return (
crm.relationship === "cliente" &&
crm.clientSince != null &&
crm.clientSince >= monthStart
)
})
.reduce((n, c) => n + totalsOf(c).auc, 0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/carteiras-view.tsx` around lines 147 -
161, Update monthStart in the funding calculation to derive the YYYY-MM-DD key
directly from the local year, month, and day parts instead of converting local
midnight through toISOString(). Preserve the existing captured filter and
totalsOf(c).auc aggregation.

Comment on lines +314 to +326
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => {
dispatch({ type: "SET_AUDIENCE", chatIds: picked })
dispatch({ type: "SET_VIEW", view: "automacoes" })
onDone()
}}
>
<Icon icon={CampaignIcon} />
Criar campanha
</Button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

"Criar campanha" opens the Regras tab, not Campanhas.

The handler sets the audience and dispatches SET_VIEW to automacoes. AutomacoesView renders Tabs with defaultValue="regras", so the user arrives on the rules list and must find the Campanhas tab. The selected audience is not visible on that tab.

Make the target tab controllable, for example by deriving the initial tab from state.campaignAudience in AutomacoesView.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/funil-view.tsx` around lines 314 - 326,
Update the navigation triggered by the “Criar campanha” button and the
corresponding AutomacoesView tab state so it opens the Campanhas tab directly
after setting the audience. Make the tab selection controllable or derive its
initial value from state.campaignAudience, while preserving the existing Regras
default when no campaign audience is selected.

Comment on lines +438 to +459
{picked.length ? (
<BulkBar picked={picked} chats={chats} onDone={() => setPicked([])} />
) : null}

<div className="overflow-x-auto rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-9">
<Checkbox
aria-label="Selecionar todos os visíveis"
checked={
rows.length > 0 && picked.length === rows.length
? true
: picked.length > 0
? "indeterminate"
: false
}
onCheckedChange={(value) =>
setPicked(value === true ? rows.map((r) => r.chat.id) : [])
}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The selection survives filter changes and then acts on hidden contacts.

picked holds chat ids and is never reconciled with rows. Two consequences follow:

  • The user selects rows, then changes query, rel, or expiring. BulkBar still acts on the ids that are no longer visible, because applyTag and moveStage resolve ids against chats, not rows.
  • The header checkbox reads picked.length === rows.length. After a filter change, that comparison can report "all selected" while none of the visible rows are selected.

Reconcile the selection against the visible rows.

🐛 Proposed fix
+  // A selection only means something for rows the user can still see.
+  const visibleIds = React.useMemo(
+    () => new Set(rows.map((r) => r.chat.id)),
+    [rows]
+  )
+  const selected = React.useMemo(
+    () => picked.filter((id) => visibleIds.has(id)),
+    [picked, visibleIds]
+  )

Then use selected for BulkBar, for the header checkbox state, and for the per-row checked value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/components/whatsapp/views/funil-view.tsx` around lines 438 - 459,
Reconcile the picked chat IDs with the currently visible rows by deriving a
selected collection from rows and picked. Use selected for BulkBar, the header
checkbox checked state and toggle behavior, and each row’s checked state, while
preserving picked only as the underlying selection state.

Comment on lines +78 to +83
case "sem-resposta": {
const limit = rule.config.days ?? 3
if (!last?.fromMe) return null
const idle = daysIdle(chat, now)
return idle >= limit ? `Sua mensagem sem resposta há ${idle} dias` : null
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Guard sem-resposta against an infinite idle count.

daysIdle returns Number.POSITIVE_INFINITY for chats without updatedAt, which is the documented state of seeded chats. The sem-contato branch filters that value with Number.isFinite, but sem-resposta does not. The rule then fires with the reason text Sua mensagem sem resposta há Infinity dias, which reaches the Agenda card and the automation event log.

Apply the same finiteness check as sem-contato.

🐛 Proposed fix
     case "sem-resposta": {
       const limit = rule.config.days ?? 3
       if (!last?.fromMe) return null
       const idle = daysIdle(chat, now)
-      return idle >= limit ? `Sua mensagem sem resposta há ${idle} dias` : null
+      return Number.isFinite(idle) && idle >= limit
+        ? `Sua mensagem sem resposta há ${idle} dias`
+        : null
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case "sem-resposta": {
const limit = rule.config.days ?? 3
if (!last?.fromMe) return null
const idle = daysIdle(chat, now)
return idle >= limit ? `Sua mensagem sem resposta há ${idle} dias` : null
}
case "sem-resposta": {
const limit = rule.config.days ?? 3
if (!last?.fromMe) return null
const idle = daysIdle(chat, now)
return Number.isFinite(idle) && idle >= limit
? `Sua mensagem sem resposta há ${idle} dias`
: null
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/lib/automation-engine.ts` around lines 78 - 83, Update the
"sem-resposta" branch in the rule handler to require Number.isFinite(idle)
before firing, matching the existing "sem-contato" guard. Preserve the current
last?.fromMe check, threshold comparison, and null result for non-qualifying
chats.

Comment thread whatsapp/lib/insights.ts
Comment on lines +45 to +51
export function fingerprint(chat: Chat): string {
const parts = [
chat.id,
String(chat.conversation.reduce((n, d) => n + d.messages.length, 0)),
chat.updatedAt ? String(chat.updatedAt) : "",
JSON.stringify(chat.crm ?? {}),
].join("|")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include all analysis inputs in the fingerprint.

The hash does not include message content, message timestamps, or the current analysis date. A cached model result remains fresh after a new day even though buildPrompt() includes a new “Hoje” value and time-sensitive actions change.

Hash a canonical transcript representation and the normalized analysis date used by the API route.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@whatsapp/lib/insights.ts` around lines 45 - 51, Update fingerprint so it
includes every input consumed by analysis: hash a canonical representation of
the conversation messages, including their content and timestamps, and include
the normalized analysis date used by the API route. Preserve the existing chat
identity, update time, and CRM inputs while ensuring equivalent canonical data
produces stable fingerprints.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant