Compare commits

...
Author SHA1 Message Date
aakashb95 9bfc54ea0c feat(captain): pass reasoning effort to v2 agents 2026-07-10 23:21:01 +05:30
aakashb95 93d4e1ca0e feat(llm): add Responses API client 2026-07-10 23:19:07 +05:30
aakashb95 090ca53bde feat(llm): configure GPT-5.6 reasoning defaults 2026-07-10 23:11:25 +05:30
98154bbeab fix(meta): show restriction alerts for inbox setup (#14974)
Instagram inbox creation and WhatsApp embedded signup on Chatwoot Cloud
now reflect the temporary Meta restriction. Instagram is hidden from
onboarding on Cloud, while the regular Instagram inbox creation page
shows a disabled action with a status-linked amber warning. WhatsApp
embedded signup on Cloud stays visible with its connect action disabled.
WhatsApp Call setup always uses the manual WhatsApp form.

Existing Instagram conversations and Instagram inbox settings on Cloud
also show amber warning banners with the public incident link.
Self-hosted installations keep their existing Instagram, WhatsApp, and
WhatsApp Call setup behavior because the temporary restriction is based
only on the Chatwoot Cloud environment check.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-10 16:22:49 +04:00
Sivin VargheseandGitHub 03a1b1dbc1 chore: insert resolved variable value in reply editor (#14921)
# Pull Request Template

## Description

This PR makes reply editor variables insert their resolved value (for
example, the contact's name) instead of the raw `{{contact.name}}`
placeholder, matching canned response behavior. This works both when
picking a variable from the `{{` menu and when an agent manually types
out `{{contact.name}}` — it resolves the moment the closing `}}` is
typed.

If a variable has no value, the `{{placeholder}}` is kept so the backend
can still resolve it when the message is sent. Private notes are left
untouched.

For safety, a resolved value that itself contains Liquid syntax `({{ }}`
or `{% %})` also keeps its placeholder, so customer-controlled fields
can never inject Liquid into the outgoing message.

Fixes
https://linear.app/chatwoot/issue/CW-7528/reply-editor-inserts-variable-placeholder-instead-of-the-value

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

1. Open a conversation and add a reply.
2. Type `{{` and pick a variable that has a value (e.g. Contact name) →
it inserts the actual value.
3. Manually type `{{contact.name}}` and close the braces → it
auto-resolves to the value.
4. Insert/type a variable with no value → the `{{placeholder}}` stays;
confirm it resolves correctly on send.
5. Repeat in a private note → placeholders are left as-is.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-10 17:24:34 +05:30
Sony MathewandGitHub 848e94bcf2 fix: throttle filtered unread count rebuilds (#14980)
Reduces database pressure from filtered unread-count cache rebuilds
during high-traffic account rollouts by serving stale snapshots longer
and limiting inline saved-filter rebuild fanout.

## Closes

None

## What changed

- Increase filtered unread-count refresh throttling from 30 seconds to 5
minutes.
- Increase the stale snapshot window from 30 minutes to 1 hour.
- Reduce inline saved-filter count rebuilds per request from 10 to 3.
- Update unread-count specs to assert refresh and stale behavior through
the shared constants.

## How to test

- Enable `conversation_unread_counts` and `unread_count_for_filters` for
an account with conversation custom filters.
- Open the dashboard and verify unread-count badges still return values.
- Mutate conversations and verify stale filtered counts are served while
rebuilds are throttled, instead of repeatedly rebuilding every 30
seconds.
2026-07-10 17:23:00 +05:30
Tanmay Deep SharmaandGitHub 56dc580f50 feat: support manual setup for WhatsApp calls inbox creation (#14976)
Re-enables the "WhatsApp Calls" inbox creation option, which disappeared
when embedded signup was disabled on production. The channel card now
only requires the `channel_voice` account feature, and the creation flow
offers the manual WhatsApp Cloud API setup form. Once the channel is
created with manual credentials, calling is enabled automatically — the
same post-setup step the embedded signup flow used to perform.

When an installation has embedded signup configured, the flow still uses
it; the manual form is the fallback (and the effective path on Chatwoot
Cloud today).

## How to test

1. Enable the `channel_voice` feature on the account.
2. Go to Add Inbox → the "WhatsApp Calls" card is visible again → select
it.
3. Fill in the manual Cloud API credentials (inbox name, phone number,
phone number ID, business account ID, API key) and submit.
4. The inbox is created and calling is enabled:
`provider_config.calling_enabled` is true and the Calls tab toggle is
on. If the number isn't enrolled in the Business Calling API, an alert
explains the enable failure but the messaging inbox is still created.

## What changed

- `ChannelItem.vue`: the `whatsapp_call` card is gated only on
`channel_voice` (previously also required the embedded signup app ID).
- `CloudWhatsapp.vue`: new `enableCallingOnComplete` prop that calls the
enable-calling API after channel creation.
- `WhatsappCall.vue`: renders embedded signup when available, otherwise
the manual setup form — both with calling enabled on completion.
2026-07-10 15:24:14 +05:30
35fcd56ba9 feat: add support action to suspended account page (#14969)
Updates the suspended account page with the revised policy copy and adds
a visible Contact support action that opens the embedded Chatwoot
support widget.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-09 16:45:47 +04:00
Aakash BakhleandGitHub d57354c8b5 feat: tighten conversation FAQ generation prompt (#14957)
Tightens the resolved-conversation FAQ generator so it only proposes
durable, reusable FAQ candidates supported by human support-agent
messages. The implementation now sends a conversation-FAQ-specific
transcript to the LLM: customer messages plus real human support-agent
messages only, excluding bot, private, activity, and template messages.

## Closes

-
https://linear.app/chatwoot/issue/CW-7494/tighten-conversation-faq-generation-prompt

## What changed

- Added a human-only transcript builder in `ConversationFaqService`
instead of using the generic `conversation.to_llm_text` output.
- Excluded bot/agent-bot messages before the LLM call, which removes the
main bot-line leakage class deterministically.
- Preserved native-channel human replies where outgoing messages are
stored as `external_echo` without a `User` sender.
- Kept a prompt decision gate requiring each FAQ to be backed by a
complete public human-agent answer.
- Added generic no-FAQ classes for spam, wrong-service conversations,
private account/payment/order/certificate/troubleshooting cases, support
workflow mechanics, and direct-link/file/quote outputs.
- Added a separate `conversation_faq_generation` model route defaulting
to `gpt-5.2`, while keeping `document_faq_generation` on its existing
`gpt-4.1-mini` default. Conversation FAQ generation passes that feature
default ahead of the legacy global `CAPTAIN_OPEN_AI_MODEL` setting
unless an account-level override is configured.
- Kept the prompt domain-neutral so it can still generate reusable
product, service, policy, setup, and process FAQs outside SaaS contexts.

## Sampling notes

- Production Langfuse traces showed `llm.captain.conversation_faq` calls
using `gpt-4.1` in the sampled account set.
- Locally, `Llm::FeatureRouter.resolve(feature:
'conversation_faq_generation')` now resolves to `gpt-5.2`.
- Reviewed recent production `llm.captain.conversation_faq` traces
across 13+ accounts in compact form.
- Replayed 20 full traces across 10 accounts/domains, including
education, hosting, retail/auto, APIs, logistics, tax/fiscal workflows,
and Chatwoot account 1.
- Explicit `gpt-5.2` replay with human-only conversation history
returned no FAQ for 15/20 traces.
- A comparison replay with `gpt-4.1-mini` returned no FAQ for only 7/20
traces, bringing back several private/order/payment/support-workflow
cases.
- Remaining non-empty `gpt-5.2` outputs are now mostly
borderline/possibly useful human-agent-derived FAQs rather than obvious
bot-sourced answers.

## How to test

- Resolve conversations where the answer came only from the bot; no
pending FAQ should be generated.
- Resolve spam, unrelated, wrong-service, or private
payment/order/account conversations; no pending FAQ should be generated.
- Resolve conversations that require account/order/payment/login/private
verification or a human handoff; no pending FAQ should be generated.
- Resolve a conversation where a human agent gives a stable, reusable
help-center answer; the generated pending FAQ should be general and
self-contained.
2026-07-09 17:47:47 +05:30
8d2ef4ec5e feat: assistant overview drilldown reports [CW-7408] (#14920)
<img width="2616" height="1716" alt="CleanShot 2026-07-09 at 14 22
42@2x"
src="https://github.com/user-attachments/assets/504e58df-0243-4cb9-a811-62504fdcb0ac"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-09 16:25:22 +05:30
bddb561546 chore: improve account suspended message (#14968)
Updates the suspended account screen copy to use clearer policy and
safety-oriented language, while giving users a direct support path if
they believe the suspension is a mistake.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-09 13:01:08 +04:00
Vishnu NarayananandGitHub 20227403ce fix: return public contact inbox payload after update (#14946)
# Pull Request Template

## Description
This keeps the public inbox contact update response on the public
contact inbox serializer shape after applying the contact identify
update.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- `bundle exec rspec
spec/controllers/public/api/v1/inbox/contacts_controller_spec.rb`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Fixes
[https://linear.app/chatwoot/issue/CW-6937](https://linear.app/chatwoot/issue/CW-6937)
Fixes
[https://linear.app/chatwoot/issue/CW-7464](https://linear.app/chatwoot/issue/CW-7464)
Fixes
[https://linear.app/chatwoot/issue/CW-7457](https://linear.app/chatwoot/issue/CW-7457)
2026-07-09 13:50:01 +05:30
Vishnu NarayananandGitHub 0a13dbedfb fix: force account picker on microsoft email inbox OAuth (#14794)
## Description

The Microsoft authorize URL had no `prompt` parameter. With Microsoft's
default behavior, a browser carrying an active Microsoft session is
silently signed in to that account. If the same account is already
attached to an inbox in the same Chatwoot account, the callback treats
the OAuth response as a re-auth and routes the user to the existing
inbox settings page instead of letting them create the new inbox they
intended.

Adding `prompt=select_account` interrupts SSO and always presents the
Microsoft account picker, so the user explicitly chooses the mailbox
they want to connect.

This is distinct from `prompt=consent`, which was removed in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) to fix the
admin consent loop. `select_account` only interrupts SSO and does not
retrigger the consent dialog.

Closes INF-76

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

`bundle exec rspec
spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb`
(3 examples, 0 failures). The regression test introduced in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) is updated to
assert `prompt=select_account` instead of asserting absence of the
parameter.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-09 13:49:33 +05:30
Muhsin KelothandGitHub 8d554f2b74 chore(inbox): disable Instagram inbox creation (#14964)
Reverts chatwoot/chatwoot#14955
2026-07-09 10:02:02 +04:00
Sony MathewandGitHub 4854fb7b4b chore: upgrade websocket-driver (#14961)
# Pull Request Template

## Description

Updates `websocket-driver` from `0.7.7` to `0.8.2` to resolve the
bundle-audit findings for CVE-2026-54463, CVE-2026-54464,
CVE-2026-54465, and GHSA-2x63-gw47-w4mm.

This is a lockfile-only transitive dependency update through Rails
Action Cable. No application code changes are included.

Fixes # (issue)
N/A

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- `bundle exec bundle audit update && bundle exec bundle audit check -v`
- `bundle check && bundle exec ruby -e 'require "action_cable"; require
"action_cable/connection/client_socket"; require "websocket/driver";
puts "actioncable=#{ActionCable::VERSION::STRING}"; puts
"websocket-driver=#{Gem.loaded_specs["websocket-driver"].version}"; puts
"driver_rack=#{WebSocket::Driver.respond_to?(:rack)}"'`
- `POSTGRES_DATABASE=chatwoot_test_3d6a RAILS_ENV=test bundle exec rails
db:prepare && POSTGRES_DATABASE=chatwoot_test_3d6a bundle exec rspec
spec/listeners/action_cable_listener_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas (not applicable; lockfile-only change)
- [ ] I have made corresponding changes to the documentation (not
applicable; dependency security update)
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works (not applicable; dependency security update)
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules (not applicable)
2026-07-08 15:52:40 -07:00
Sony MathewandGitHub 66cfb26c77 feat: Add unread count filters feature flag (1/6) (#14885)
## Description

Adds the account-level `unread_count_for_filters` feature flag as the
dark-launch gate for filtered sidebar unread counts. This reuses the
deprecated `quoted_email_reply` flag slot, resets the reused bit for
existing accounts, and removes stale defaults so new accounts do not
reference the old flag.

This also adds the feature where we are now calculating the unread counts for built in filters like mentions, participating and unattended along with unread count for saved filters/folders.

Closes
[CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
2026-07-08 23:50:40 +05:30
Shivam MishraandGitHub b9536fb8ed fix: use reserved example domain in SAML placeholders (#14958) 2026-07-08 21:55:38 +05:30
Sony MathewandGitHub 873d16f54c feat: Extend account feature flag storage (#14947)
# Pull Request Template

## Description

Extends account-level feature flags by adding a second bigint bitset
column, `feature_flags_ext_2`, while preserving the existing
`flag_shih_tzu` feature check and enable/disable APIs. Existing flags
continue to live on `feature_flags`; future flags can opt into the
extension column through `config/features.yml` metadata.

Fixes
[CW-7238](https://linear.app/chatwoot/issue/CW-7238/feature-flag-extension)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- `eval "$(rbenv init -)" && RAILS_ENV=test
POSTGRES_DATABASE=chatwoot_test_31a6 bundle exec rspec
spec/models/concerns/featurable_spec.rb spec/models/account_spec.rb
spec/lib/config_loader_spec.rb
spec/controllers/platform/api/v1/accounts_controller_spec.rb
spec/controllers/super_admin/accounts_controller_spec.rb
spec/enterprise/models/account_spec.rb` - 144 examples, 0 failures
- `eval "$(rbenv init -)" && bundle exec rubocop
app/models/concerns/featurable.rb app/models/account.rb
db/migrate/20260706215758_add_feature_flags_ext_2_to_accounts.rb
spec/models/concerns/featurable_spec.rb spec/models/account_spec.rb
spec/lib/config_loader_spec.rb spec/enterprise/models/account_spec.rb` -
7 files inspected, no offenses detected
- `ruby -ryaml -e "features =
YAML.safe_load(File.read('config/features.yml')); abort unless
features.size == 63; puts features.group_by { |f| f['column'] ||
'feature_flags' }.transform_values(&:size).inspect"` - `{"feature_flags"
=> 63}`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-08 17:38:26 +05:30
d3c588ff10 chore(inbox): re-enable Instagram inbox creation (#14955)
Brings the Instagram channel back in inbox creation and onboarding.
Instagram was temporarily disabled along with WhatsApp embedded signup
in #14943; this re-enables Instagram while keeping WhatsApp embedded
signup and WhatsApp Call inbox creation disabled.

Fixes https://linear.app/chatwoot/issue/CW-7549/enable-instagram

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-08 17:37:46 +05:30
Tanmay Deep SharmaandGitHub f6c18f5225 feat: account calls dashboard index endpoint (#14780)
## Description

Adds a backend endpoint that powers an account-wide calls dashboard,
letting users list and filter all calls in the account.

## Linear Ticket
- https://linear.app/chatwoot/issue/UPM-28/voice-call-dashboard-view

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-08 15:31:03 +05:30
Sojan JoseandGitHub 0e07a27c74 fix: enforce inbox limits at model level (#14949)
Fixes https://linear.app/chatwoot/issue/CW-7559/inbox-limit-abuse

## Why

The regular inbox API checked limits in the controller, but WhatsApp
embedded signup creates inboxes through a service using `Inbox.create!`.
That let Enterprise account inbox limits be skipped for embedded signup.

## What this change does

- Adds an Inbox create-time validation hook in OSS and implements the
limit check in the Enterprise Inbox module.
- Removes the duplicate controller/helper limit check so the model is
the single enforcement point.
- Preserves the existing `402 Payment Required` API response for account
inbox limit failures.
- Keeps updates to existing inboxes allowed when an account is already
at its inbox limit.

## Validation

- `bundle exec rspec
spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
spec/enterprise/models/inbox_spec.rb`
2026-07-08 13:18:07 +04:00
ce8c8e9a11 fix: sanitize control characters in team names (#14865)
Team names created via the API could contain control characters (for
example a trailing newline). Because the team-delete confirmation dialog
requires you to retype the team name and matches it against the stored
value, a hidden control character meant the typed name never matched —
leaving the team impossible to delete from the UI. This sanitizes team
names on save so they stay clean and deletable.

#### How to reproduce
1. Create a team via `POST /api/v1/accounts/{account_id}/teams` with
`{"name": "test\n"}`.
2. The team is created with the trailing newline stored in `name`.
3. In **Settings → Teams**, click delete and type the team name to
confirm — the match fails, so the team cannot be deleted.

#### What changed
- `app/models/team.rb`: the existing `before_validation` now strips
control characters and surrounding whitespace before downcasing the
name. Names that reduce to blank (e.g. only newlines/tabs) are rejected
loudly by the existing `presence` validation.
- Fixing at the model layer covers the API and every other create/update
path, rather than relying on the frontend confirm-dialog `.trim()`
(which only handles leading/trailing whitespace, not internal control
characters).

Note: this prevents new malformed names. Any team already saved with a
control character can be made deletable again simply by renaming it (an
update re-runs the same sanitization).

| Input | Stored as | Result |
|---|---|---|
| `"test\n"` | `"test"` | valid, deletable |
| `"te\nst"` (internal) | `"test"` | valid |
| `"\t\n "` (only control/ws) | — | rejected: "Name must not be blank" |
| `"Customer Support"` | `"customer support"` | unchanged behavior |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:04:24 -07:00
Shivam MishraandGitHub 29d0b92f1c fix: captain hours saved metric (#14948)
Reworks the Captain assistant "hours saved" metric so it produces a
believable number. It previously multiplied assistant reply count by
customer reply-wait time, which inflated the figure into the millions of
hours. It now estimates saved time as replies × a fixed ~2 min per-reply
effort assumption. On the overview card the value renders in days once
it passes 100 hours, and the label/hint copy was updated to match.

## How to test
1. Open the Captain assistant overview page.
2. Check the "Time saved" card shows a sensible value (hours, or days
past 100h).
3. Hover the hint and confirm it reflects the per-reply estimate.
2026-07-07 19:16:24 +05:30
a5fcecb3f6 feat: assistant overview page [CW-7408] (#14889)
This PR adds a Captain Assistant **Overview** page to show some KPI
metrics (conversations handled, auto-resolution, handoff, hours saved,
reopen-after-resolve, conversation depth) with trend deltas vs the
previous window, a real knowledge card, and a lazily-loaded, cached LLM
welcome summary.

### Highlights

- **Two contextual banners** on the overview:
- **Inbox banner** — prompts the user to connect an inbox when the
assistant has none, so it can actually do work.
- **Coverage banner** — warns when FAQ coverage is below 85% with more
than 100 responses pending review, linking straight to the pending
queue. Dismissal persists per-assistant for 24h via localStorage.
- **Batched stats builder** (`Captain::AssistantStatsBuilder`) computes
both windows in single FILTER-aggregated scans to cut round trips,
behind new `stats`/`summary` endpoints.
- **Cards included but intentionally left dummy / not rendered yet:**
`ResponseQualityCard` (flagged responses) and `CreditUsageCard` (credit
usage + daily chart). Credits are an account-wide counter with no
per-assistant or daily history, so there is no real data to back them
yet; they ship in the codebase but are not wired into the page.

### Index migration

- Replaces `index_messages_on_sender_type_and_sender_id` with
`index_messages_on_sender_and_created` `(sender_type, sender_id,
created_at)`.
- **Why it helps:** the per-assistant windowed lookups filter `sender_*`
*and* a `created_at` range. The old 2-column index matched every
lifetime row for the assistant and filtered the time slice at the heap
(~89% of rows discarded); adding `created_at` as a range column lets
Postgres scan only the window, and fixes the row-count estimate so the
planner picks a hash join over a nested loop on `reporting_events`.
- **Why dropping the old index is safe:** the new index is a left-prefix
superset `(sender_type, sender_id, ...)`, so every query the old one
served is still served. No code references it by name, and dropping it
keeps write amplification on `messages` neutral. Built/dropped with
`CONCURRENTLY` and `if_not_exists`/`if_exists` guards.


## Preview

<img width="2572" height="1754" alt="CleanShot 2026-06-29 at 22 38
51@2x"
src="https://github.com/user-attachments/assets/3798d09e-7850-48e4-b2cd-508533f15cea"
/>

## Banners

#### Inbox connect alert

<img width="2178" height="612" alt="CleanShot 2026-06-30 at 14 26 55@2x"
src="https://github.com/user-attachments/assets/373c371c-bb7d-4291-a0f9-620673078302"
/>

#### Coverage alert
<img width="2178" height="612" alt="CleanShot 2026-06-30 at 14 25 41@2x"
src="https://github.com/user-attachments/assets/e12d6308-11b6-4ba2-88a2-8a3077dd3e8f"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-07 17:54:13 +05:30
Vishnu NarayananandGitHub 1e7218d439 fix: compare HMAC identity hashes securely (#14945)
This switches widget and public inbox HMAC identity checks from direct
string equality to constant-time comparison with a length guard.

Fixes
[https://linear.app/chatwoot/issue/CW-6937](https://linear.app/chatwoot/issue/CW-6937)
Fixes
[https://linear.app/chatwoot/issue/CW-7464](https://linear.app/chatwoot/issue/CW-7464)
Fixes
[https://linear.app/chatwoot/issue/CW-7227](https://linear.app/chatwoot/issue/CW-7227)
2026-07-07 17:37:22 +05:30
c8bfbadae2 fix: validate identifier hash on widget contact update (#14884)
## Description

The widget contact update endpoint (`PATCH /api/v1/widget/contact`)
applied updates that set an `identifier` without validating the
`identifier_hash`, unlike `set_user`, which already does. This change
runs the same validation on the update path whenever an `identifier` is
supplied, keeping identity validation consistent across the widget
contact endpoints.

Anonymous updates that don't pass an identifier (prechat
name/email/phone and custom attributes) keep working unchanged,
including on inboxes with mandatory identity validation.

Fixes https://linear.app/chatwoot/issue/CW-7118


---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-07 16:40:19 +05:30
Aakash BakhleandGitHub 83dda621c5 feat: default new accounts to captain v2 (#14917)
# Pull Request Template

## Description

defaults new accounts to captain v2

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and specs

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-07 16:21:51 +05:30
Sony MathewandGitHub eab859cd04 fix: Sanitize query canceled API errors (#14933)
# Pull Request Template

## Description

Message creation API failures caused by PostgreSQL query cancellations
now return a generic retryable error instead of exposing raw database
internals such as `PG::QueryCanceled`, tuple identifiers, or relation
names to customers. Existing validation failures continue to return
their specific validation messages.

Closes
[CW-7538](https://linear.app/chatwoot/issue/CW-7538/do-not-expose-pgquerycanceled-details-in-messages-api-errors)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Reproduced the messages API path by raising
`ActiveRecord::QueryCanceled` from `Messages::MessageBuilder` and
verified the response contains the customer-safe localized error without
`PG::QueryCanceled` details.

Validation run locally:

- `bundle exec rspec
spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb`
- `bundle exec rubocop
app/controllers/concerns/request_exception_handler.rb
spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-07 16:20:52 +05:30
Sojan JoseandGitHub b823764199 refactor: optimize agent bot assignment dropdown (#14866) 2026-07-07 14:25:54 +05:30
397ac2e18a chore(inbox): temporarily disable Instagram and WhatsApp embedded signup inbox creation (#14943)
This temporarily turns off inbox creation for channels that rely on
Meta's embedded signup — the WhatsApp Cloud signup popup, Instagram
(OAuth-only), and WhatsApp Call. WhatsApp Cloud inbox creation now falls
back to the manual API-key form, while the Instagram and WhatsApp Call
tiles appear disabled. Both channels are also hidden from the
new-account onboarding flow. Existing inboxes are not affected.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-07 12:18:17 +04:00
Tanmay Deep SharmaandGitHub 11deffdd5d feat: billing brl pix new users (#14617)
## Linear ticket
- https://linear.app/chatwoot/issue/CW-7253/billing-brl-pix-new-users

## Description

New accounts that sign up in Brazilian Portuguese are now billed in BRL
instead of USD. Their Stripe customer is created with a Brazil address
and Portuguese locale (so the Stripe portal offers Real prices and PIX),
and the AI credit top-up flow shows packages priced in the account's
billing currency. Currency support is config-driven, so adding another
currency later is a configuration change rather than a code change.


## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- https://www.loom.com/share/c8d3d08c1b844ed6b820438d4209491a

## Screenshot
<img width="904" height="440" alt="image"
src="https://github.com/user-attachments/assets/6f19fad8-e6af-46ea-b99f-b0265bb9eeec"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-06 16:35:38 +05:30
249 changed files with 10326 additions and 365 deletions
+1 -1
View File
@@ -1030,7 +1030,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.7)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -2,6 +2,8 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
def index
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
@include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
@@ -10,6 +12,7 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end
private
@@ -6,6 +6,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token,
@@ -15,6 +16,8 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
end
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
@@ -66,6 +66,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
@@ -74,4 +75,10 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
end
@@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create
process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -1,27 +1,40 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
def show
@participants = @conversation.conversation_participants
end
def create
participant_ids_to_add = participants_to_be_added_ids
ActiveRecord::Base.transaction do
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end
notify_unread_count_change if participant_ids_to_add.any?
end
def update
participant_ids_to_add = participants_to_be_added_ids
participant_ids_to_remove = participants_to_be_removed_ids
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
ActiveRecord::Base.transaction do
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants
render action: 'show'
end
def destroy
participant_ids_to_remove = current_participant_ids & params[:user_ids]
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if participant_ids_to_remove.any?
head :ok
end
@@ -38,4 +51,11 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end
def notify_unread_count_change
return unless Current.account.feature_enabled?('conversation_unread_counts')
return unless Current.account.feature_enabled?('unread_count_for_filters')
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
end
end
@@ -2,12 +2,28 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
counts = if filtered_unread_counts_enabled?
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
else
unread_counts
end
render json: { payload: counts }
end
private
def unread_counts
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
end
def filtered_unread_counts_enabled?
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
@@ -164,6 +164,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end
def should_update_last_seen?
@@ -2,7 +2,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
@@ -6,7 +6,10 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state
state: state,
# Force the Microsoft account picker so an already-signed-in account does not
# silently authorize and re-bind to an existing inbox in the new-inbox flow.
prompt: 'select_account'
}
)
if redirect_url
@@ -8,8 +8,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue StandardError => e
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_embedded_signup_error(e)
end
private
@@ -55,7 +57,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response
end
def render_error_response(error)
def render_embedded_signup_error(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
@@ -2,6 +2,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
def show; end
@@ -46,6 +47,16 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
# The plain update endpoint is also used for anonymous prechat updates
# (name/email/phone/custom_attributes with no identifier), which must keep
# working on hmac_mandatory inboxes. Only the identity-binding path, where an
# identifier is supplied and the contact can be rebound, requires HMAC.
def validate_hmac_for_identified_update
return if params[:identifier].blank?
validate_hmac
end
def validate_hmac
return unless should_verify_hmac?
@@ -62,11 +73,15 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
expected_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -1,8 +1,15 @@
module RequestExceptionHandler
extend ActiveSupport::Concern
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
'ActiveRecord::QueryCanceled',
'PG::QueryCanceled',
'canceling statement due to statement timeout'
].freeze
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
private
@@ -18,6 +25,9 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message)
rescue ActiveRecord::QueryCanceled => e
log_handled_error(e)
render_could_not_create_error(database_query_canceled_message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset
@@ -31,8 +41,8 @@ module RequestExceptionHandler
render json: { error: message }, status: :not_found
end
def render_could_not_create_error(message)
render json: { error: message }, status: :unprocessable_entity
def render_could_not_create_error(error)
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
end
def render_payment_required(message)
@@ -59,4 +69,19 @@ module RequestExceptionHandler
def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}")
end
def sanitized_error_message(message)
return database_query_canceled_message if database_query_canceled_message?(message)
message
end
def database_query_canceled_message?(message)
error_message = message.to_s
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
end
def database_query_canceled_message
I18n.t('errors.database.query_canceled')
end
end
@@ -11,6 +11,8 @@ class Instagram::CallbacksController < ApplicationController
end
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -47,6 +49,14 @@ class Instagram::CallbacksController < ApplicationController
redirect_to_error_page(error_info)
end
def handle_limit_error(error)
redirect_to_error_page(
'error_type' => error.class.name,
'code' => Rack::Utils.status_code(error.http_status),
'error_message' => error.message
)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
@@ -18,7 +18,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
contact: @contact_inbox.contact,
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
)
render json: contact_identify_action.perform
contact_identify_action.perform
@contact_inbox.reload
end
private
@@ -35,11 +36,15 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
expected_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -6,6 +6,8 @@ class Tiktok::CallbacksController < ApplicationController
return handle_ungranted_scopes_error unless all_scopes_granted?
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -36,6 +38,14 @@ class Tiktok::CallbacksController < ApplicationController
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
end
def handle_limit_error(error)
redirect_to_error_page(
error_type: error.class.name,
code: Rack::Utils.status_code(error.http_status),
error_message: error.message
)
end
# Handles the case when a user denies permissions or cancels the authorization flow
def handle_authorization_error
redirect_to_error_page(
-6
View File
@@ -114,10 +114,4 @@ module Api::V1::InboxesHelper
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
def validate_limit
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
end
end
+12
View File
@@ -22,4 +22,16 @@ module BillingHelper
def agents(account)
account.users.count
end
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
def subscription_period_end(subscription)
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
end
def subscription_ends_on(subscription)
period_end = subscription_period_end(subscription)
return if period_end.blank?
Time.zone.at(period_end)
end
end
+10
View File
@@ -68,6 +68,8 @@ module Filters::FilterHelper
when 'text_case_insensitive'
text_case_insensitive_filter(query_hash, filter_operator_value)
else
return text_cast_filter(query_hash, filter_operator_value) if text_search_on_display_id?(query_hash)
default_filter(query_hash, filter_operator_value)
end
end
@@ -82,10 +84,18 @@ module Filters::FilterHelper
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_cast_filter(query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::text #{filter_operator_value} #{query_hash[:query_operator]}"
end
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_search_on_display_id?(query_hash)
query_hash[:attribute_key] == 'display_id' && %w[contains does_not_contain].include?(query_hash[:filter_operator])
end
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
@@ -6,9 +6,12 @@ class AssignableAgents extends ApiClient {
super('assignable_agents', { accountScoped: true });
}
get(inboxIds) {
get(inboxIds, { includeAgentBots = false } = {}) {
return axios.get(this.url, {
params: { inbox_ids: inboxIds },
params: {
inbox_ids: inboxIds,
...(includeAgentBots ? { include_agent_bots: true } : {}),
},
});
}
}
@@ -1,6 +1,10 @@
/* global axios */
import ApiClient from '../ApiClient';
// Viewer's UTC offset in hours, matching the reports API convention so the
// backend can anchor calendar ranges to the viewer's day.
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
@@ -21,6 +25,32 @@ class CaptainAssistant extends ApiClient {
message_history: messageHistory,
});
}
getStats({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/stats`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getSummary({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getDrilldown({ assistantId, metric, range, page, signal }) {
const requestConfig = {
params: {
metric,
range,
timezone_offset: getTimezoneOffset(),
page,
},
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/drilldown`, requestConfig);
}
}
export default new CaptainAssistant();
@@ -14,6 +14,10 @@ class EnterpriseAccountAPI extends ApiClient {
return axios.post(`${this.url}subscription`);
}
selectBillingCurrency(currency) {
return axios.post(`${this.url}select_billing_currency`, { currency });
}
getLimits() {
return axios.get(`${this.url}limits`);
}
@@ -27,6 +31,11 @@ class EnterpriseAccountAPI extends ApiClient {
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
// Topup packages for the account's billing currency.
getTopupOptions() {
return axios.get(`${this.url}topup_options`);
}
}
export default new EnterpriseAccountAPI();
@@ -62,9 +62,10 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId }) {
assignAgent({ conversationId, agentId, assigneeType }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
@@ -26,5 +26,15 @@ describe('#AssignableAgentsAPI', () => {
},
});
});
it('#getAssignableAgents with agent bots', () => {
assignableAgentsAPI.get([1], { includeAgentBots: true });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
include_agent_bots: true,
},
});
});
});
});
@@ -90,11 +90,16 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -182,7 +182,8 @@ const handleCreateAssistant = () => {
</div>
</div>
<div class="flex gap-2">
<div class="flex items-center gap-2">
<slot name="headerActions" />
<slot name="search" />
<div
v-if="!showPaywall && buttonLabel"
@@ -0,0 +1,234 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
open: { type: Boolean, default: false },
assistantId: { type: [String, Number], default: null },
metric: { type: String, default: '' },
metricName: { type: String, default: '' },
metricValue: { type: String, default: '' },
range: { type: String, default: '' },
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const drawerRef = ref(null);
const {
records,
meta,
isFetching,
isFetchingMore,
hasError,
hasRecords,
hasMore,
open: openDrilldown,
close,
loadMore,
} = useReportDrilldown(params => CaptainAssistant.getDrilldown(params));
let previousActiveElement = null;
const isOpen = computed(() => props.open);
const title = computed(() => props.metricName || '');
const subtitle = computed(() => {
if (!meta.value.conversation_count) return '';
return t('CAPTAIN.OVERVIEW.DRILLDOWN.RESULT_COUNT_CONVERSATION', {
count: meta.value.conversation_count,
});
});
const recordKey = record => `${record.conversation?.id}-${record.occurred_at}`;
const restoreFocus = () => {
if (previousActiveElement?.isConnected) {
previousActiveElement.focus();
}
previousActiveElement = null;
};
const closeDrawer = () => {
close();
emit('close');
restoreFocus();
};
const rememberActiveElement = () => {
if (previousActiveElement) return;
previousActiveElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
};
const focusDrawer = () => {
nextTick(() => drawerRef.value?.focus());
};
const fetchDrilldown = () => {
if (!props.metric || !props.assistantId) return;
openDrilldown({
assistantId: props.assistantId,
metric: props.metric,
range: props.range,
});
};
const onKeydown = event => {
if (!isOpen.value) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeDrawer();
}
};
useEventListener(document, 'keydown', onKeydown);
watch(
() => props.open,
isDrawerOpen => {
if (!isDrawerOpen) {
close();
restoreFocus();
return;
}
rememberActiveElement();
fetchDrilldown();
focusDrawer();
},
{ immediate: true }
);
watch(
() => [props.metric, props.range],
() => {
if (props.open) fetchDrilldown();
}
);
// The drawer's headline value is a snapshot of the previous assistant's card,
// so switching assistants (e.g. browser Back/Forward) closes it instead of
// refetching records that would no longer match the header.
watch(
() => props.assistantId,
() => {
if (props.open) closeDrawer();
}
);
onBeforeUnmount(() => {
restoreFocus();
});
</script>
<template>
<TeleportWithDirection to="body">
<Transition name="report-drilldown-fade">
<div
v-if="isOpen"
class="fixed inset-0 z-50 bg-black/30"
role="presentation"
@click.self="closeDrawer"
>
<aside
ref="drawerRef"
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
>
<header
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
>
<div class="min-w-0">
<h2 class="truncate text-base font-medium text-n-slate-12">
{{ title }}
</h2>
<p
v-if="metricValue"
class="mt-1 text-xl font-semibold text-n-slate-12"
>
{{ metricValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': metricValue,
'mt-1': !metricValue,
}"
>
{{ subtitle }}
</div>
</div>
<Button
ghost
slate
size="sm"
icon="i-ph-x"
:aria-label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.CLOSE')"
@click="closeDrawer"
/>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
<div
v-if="isFetching"
class="flex h-40 items-center justify-center"
>
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</div>
</aside>
</div>
</Transition>
</TeleportWithDirection>
</template>
@@ -0,0 +1,96 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const route = useRoute();
const router = useRouter();
// Dismissal is remembered per assistant for 24 hours (setFlag's default expiry).
const DISMISS_STORE = 'captain_overview_coverage_banner';
const accountId = computed(() => route.params.accountId);
const assistantId = computed(() => route.params.assistantId);
// Re-read the stored flag whenever the assistant changes, otherwise the banner
// would keep the first assistant's dismissed state after switching.
const dismissed = ref(false);
watch(
[accountId, assistantId],
([account, assistant]) => {
dismissed.value = LocalStorage.getFlag(DISMISS_STORE, account, assistant);
},
{ immediate: true }
);
// Thin coverage paired with a large review backlog: approving the pending FAQs
// is the quickest lever to lift auto-resolution, so nudge the team to act.
const COVERAGE_THRESHOLD = 85;
const PENDING_THRESHOLD = 100;
const showBanner = computed(
() =>
!dismissed.value &&
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
);
const dismiss = () => {
LocalStorage.setFlag(DISMISS_STORE, accountId.value, assistantId.value);
dismissed.value = true;
};
const goToPending = () => {
router.push({
name: 'captain_assistants_responses_pending',
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{
$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
count: knowledge.pending,
coverage: knowledge.coverage,
})
}}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToPending"
>
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.DISMISS')"
@click="dismiss"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
const route = useRoute();
const router = useRouter();
const store = useStore();
const dismissed = ref(false);
const assistantId = computed(() => route.params.assistantId);
const inboxes = useMapGetter('captainInboxes/getRecords');
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
// Only surface once we know the assistant has no connected inboxes.
const showBanner = computed(
() =>
!dismissed.value &&
!uiFlags.value.fetchingList &&
inboxes.value.length === 0
);
watch(
assistantId,
id => {
dismissed.value = false;
if (id) store.dispatch('captainInboxes/get', { assistantId: id });
},
{ immediate: true }
);
const goToInboxes = () => {
router.push({
name: 'captain_assistants_inboxes_index',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.TEXT') }}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToInboxes"
>
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.INBOX_BANNER.DISMISS')"
@click="dismissed = true"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,87 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const { t } = useI18n();
const route = useRoute();
const approvedPct = computed(() => props.knowledge.coverage ?? 0);
const linkTo = routeName => ({
name: routeName,
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
const stats = computed(() => [
{
key: 'approved',
value: props.knowledge.approved,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.APPROVED'),
to: linkTo('captain_assistants_responses_index'),
},
{
key: 'pending',
value: props.knowledge.pending,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
to: linkTo('captain_assistants_responses_pending'),
},
{
key: 'documents',
value: props.knowledge.documents,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.DOCUMENTS'),
to: linkTo('captain_assistants_documents_index'),
},
]);
</script>
<template>
<div
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.TITLE') }}
</span>
<span class="text-sm tabular-nums text-n-slate-11">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
</span>
</div>
<div class="w-full h-2 overflow-hidden rounded-full bg-n-alpha-2">
<div
class="h-full rounded-full bg-n-brand"
:style="{ width: `${approvedPct}%` }"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<RouterLink
v-for="stat in stats"
:key="stat.key"
:to="stat.to"
class="flex flex-col gap-1 group/stat"
>
<span class="text-xl font-semibold tabular-nums text-n-slate-12">
{{ stat.value }}
</span>
<span
class="inline-flex items-center gap-1 text-xs transition-colors text-n-slate-11 group-hover/stat:text-n-slate-12"
>
{{ stat.label }}
<span
class="transition-opacity opacity-0 i-lucide-arrow-up-right size-3 group-hover/stat:opacity-100"
/>
</span>
</RouterLink>
</div>
</div>
</template>
@@ -0,0 +1,59 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: { type: String, required: true },
value: { type: String, required: true },
trend: { type: String, default: '' },
hint: { type: String, default: '' },
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const trendClass = computed(() => {
if (props.trendGood === null) return 'text-n-slate-11';
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
});
const onActivate = () => {
if (props.clickable) emit('click');
};
</script>
<template>
<div
class="flex flex-col gap-3 p-5 group bg-n-solid-1"
:class="
clickable
? 'cursor-pointer transition-colors hover:bg-n-slate-2/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand'
: ''
"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
@click="onActivate"
@keydown.enter.self.prevent="onActivate"
@keydown.space.self.prevent="onActivate"
>
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
<span
v-if="hint"
v-tooltip="hint"
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
</div>
<div class="flex items-end justify-between gap-2">
<span
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
{{ value }}
</span>
<span class="text-sm font-medium tabular-nums" :class="trendClass">
{{ trend }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { getHelpUrlForFeature } from 'dashboard/helper/featureHelper';
const { t } = useI18n();
const route = useRoute();
const { isOnChatwootCloud } = useAccount();
const assistantParams = computed(() => ({
accountId: route.params.accountId,
assistantId: route.params.assistantId,
}));
const links = computed(() => [
{
key: 'docs',
title: t('CAPTAIN.OVERVIEW.LINKS.DOCS.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.DOCS.DESCRIPTION'),
icon: 'i-lucide-book-open',
href: getHelpUrlForFeature('captain'),
},
{
key: 'playground',
title: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.DESCRIPTION'),
icon: 'i-lucide-flask-conical',
to: {
name: 'captain_assistants_playground_index',
params: assistantParams.value,
},
},
{
key: 'billing',
title: t('CAPTAIN.OVERVIEW.LINKS.BILLING.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.BILLING.DESCRIPTION'),
icon: 'i-lucide-credit-card',
to: {
name: 'billing_settings_index',
params: { accountId: route.params.accountId },
},
},
]);
</script>
<template>
<div v-if="isOnChatwootCloud" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<component
:is="link.href ? 'a' : RouterLink"
v-for="link in links"
:key="link.key"
:href="link.href"
:to="link.to"
:target="link.href ? '_blank' : undefined"
:rel="link.href ? 'noopener noreferrer' : undefined"
class="flex items-center gap-3 p-4 transition-colors border rounded-xl bg-n-solid-1 border-n-weak hover:bg-n-alpha-1 group/link"
>
<span
class="grid rounded-lg size-9 shrink-0 place-content-center bg-n-alpha-2 text-n-slate-11"
>
<span :class="link.icon" class="size-4" />
</span>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-n-slate-12">
{{ link.title }}
</span>
<span class="text-xs truncate text-n-slate-11">
{{ link.description }}
</span>
</div>
<span
:class="
link.href ? 'i-lucide-arrow-up-right' : 'i-lucide-chevron-right'
"
class="ml-auto transition-opacity opacity-0 size-4 text-n-slate-10 group-hover/link:opacity-100"
/>
</component>
</div>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const modelValue = defineModel({ type: String, default: '30' });
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const DAY_RANGES = ['7', '30', '90'];
const decorate = item => ({
...item,
action: 'select',
isSelected: item.value === modelValue.value,
});
const menuSections = computed(() => {
const dayItems = DAY_RANGES.map(value =>
decorate({
value,
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_DAYS', { count: value }),
})
);
const monthItems = [
decorate({
value: 'this_month',
label: t('CAPTAIN.OVERVIEW.RANGES.THIS_MONTH'),
}),
decorate({
value: 'last_month',
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_MONTH'),
}),
];
return [{ items: dayItems }, { items: monthItems }];
});
const menuItems = computed(() =>
menuSections.value.flatMap(section => section.items)
);
const selectedLabel = computed(
() => menuItems.value.find(item => item.isSelected)?.label || ''
);
const handleAction = ({ value }) => {
toggleDropdown(false);
modelValue.value = value;
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
trailing-icon
icon="i-lucide-chevron-down"
:label="selectedLabel"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-sections="menuSections"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
const props = defineProps({
range: {
type: String,
default: '30',
},
});
const route = useRoute();
const assistantId = computed(() => route.params.assistantId);
// Markdown summary generated by the model from the assistant's stats (served by
// the captain/assistants/:id/summary endpoint). Numbers are emphasised with
// **bold** so we can highlight them (see prose-strong styling below).
const welcomeMarkdown = ref('');
const isLoading = ref(false);
const fetchSummary = async () => {
isLoading.value = true;
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
});
welcomeMarkdown.value = data.message ?? '';
} catch {
welcomeMarkdown.value = '';
} finally {
isLoading.value = false;
}
};
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
// handles paragraphs, line breaks, links, lists and emphasis consistently.
const formattedSummary = computed(
() => new MessageFormatter(welcomeMarkdown.value).formattedMessage
);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="isLoading || welcomeMarkdown" class="flex flex-col gap-3">
<div class="flex items-center gap-1.5 text-n-slate-10">
<span class="i-lucide-sparkles size-3.5" />
<span class="text-xs">
{{ $t('CAPTAIN.OVERVIEW.WELCOME.LABEL') }}
</span>
</div>
<div
v-if="isLoading"
class="flex flex-col gap-5"
:aria-label="$t('CAPTAIN.OVERVIEW.WELCOME.LOADING')"
>
<div class="flex flex-col gap-2.5">
<div class="w-full h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-11/12 h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-4/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="w-5/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div
v-else
v-dompurify-html="formattedSummary"
class="max-w-none prose prose-p:text-lg prose-p:leading-relaxed prose-p:mt-0 prose-p:mb-3 last:prose-p:mb-0 prose-strong:font-bold prose-strong:tabular-nums prose-strong:text-n-brand text-n-slate-12"
/>
</div>
</template>
@@ -65,7 +65,7 @@ const handleAssistantChange = async assistant => {
const currentRouteName = route.name;
const targetRouteName =
currentRouteName || 'captain_assistants_responses_index';
currentRouteName || 'captain_assistants_overview_index';
await fetchDataForRoute(targetRouteName, assistant.id);
@@ -75,6 +75,16 @@ const hasConversationUnreadCounts = computed(() => {
);
});
const hasFilteredUnreadCounts = computed(() => {
return (
hasConversationUnreadCounts.value &&
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS
)
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
@@ -199,6 +209,18 @@ const getLabelUnreadCount = useMapGetter(
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
const mentionsUnreadCount = useMapGetter(
'conversationUnreadCounts/getMentionsUnreadCount'
);
const participatingUnreadCount = useMapGetter(
'conversationUnreadCounts/getParticipatingUnreadCount'
);
const unattendedUnreadCount = useMapGetter(
'conversationUnreadCounts/getUnattendedUnreadCount'
);
const getFolderUnreadCount = useMapGetter(
'conversationUnreadCounts/getFolderUnreadCount'
);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -226,14 +248,22 @@ watch([accountId, currentUserId], fetchSidebarSortPreferences, {
immediate: true,
});
const hasUnreadCountsForSection = section => {
if (section === SIDEBAR_SORT_SECTIONS.FOLDERS) {
return hasFilteredUnreadCounts.value;
}
return hasConversationUnreadCounts.value;
};
const getSortOptionsForSection = section =>
getSidebarSortOptions(section, {
hasUnreadCounts: hasConversationUnreadCounts.value,
hasUnreadCounts: hasUnreadCountsForSection(section),
});
const getSortForSection = section =>
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
hasUnreadCounts: hasConversationUnreadCounts.value,
hasUnreadCounts: hasUnreadCountsForSection(section),
});
const updateSortPreference = (section, sortBy) => {
@@ -253,6 +283,7 @@ const sortedFolders = computed(() =>
sortSidebarItems(conversationCustomViews.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
labelKey: view => view.name,
unreadCountKey: view => getFolderUnreadCount.value(view.id),
})
);
@@ -342,6 +373,9 @@ const menuItems = computed(() => {
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
icon: 'i-lucide-at-sign',
badgeCount: hasFilteredUnreadCounts.value
? mentionsUnreadCount.value
: 0,
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
@@ -349,6 +383,9 @@ const menuItems = computed(() => {
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
icon: 'i-lucide-user-round-check',
badgeCount: hasFilteredUnreadCounts.value
? participatingUnreadCount.value
: 0,
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
@@ -357,6 +394,9 @@ const menuItems = computed(() => {
activeOn: ['conversation_through_unattended'],
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
icon: 'i-lucide-clock-alert',
badgeCount: hasFilteredUnreadCounts.value
? unattendedUnreadCount.value
: 0,
to: accountScopedRoute('conversation_unattended'),
},
{
@@ -370,6 +410,9 @@ const menuItems = computed(() => {
children: sortedFolders.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
badgeCount: hasFilteredUnreadCounts.value
? getFolderUnreadCount.value(view.id)
: 0,
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
@@ -440,6 +483,14 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN'),
activeOn: ['captain_assistants_create_index'],
children: [
{
name: 'Overview',
label: t('SIDEBAR.CAPTAIN_OVERVIEW'),
activeOn: ['captain_assistants_overview_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_overview_index',
}),
},
{
name: 'FAQs',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
@@ -44,6 +44,12 @@ const showChatSupport = computed(() => {
);
});
const toggleChatSupport = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
const menuItems = computed(() => {
return [
{
@@ -51,9 +57,7 @@ const menuItems = computed(() => {
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy',
click: () => {
window.$chatwoot.toggle();
},
click: toggleChatSupport,
},
{
show: true,
@@ -52,18 +52,10 @@ const isActive = computed(() => {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'voice') {
if (key === 'voice' || key === 'whatsapp_call') {
return props.enabledFeatures.channel_voice;
}
if (key === 'whatsapp_call') {
return (
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
}
return [
'website',
'twilio',
@@ -62,6 +62,7 @@ import {
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
createVariableInputRule,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -306,6 +307,10 @@ const plugins = computed(() => {
searchTerm: variableSearchTerm,
isAllowed: () => !props.isPrivate,
}),
createVariableInputRule({
isPrivate: () => props.isPrivate,
getVariables: () => props.variables,
}),
createSuggestionPlugin({
trigger: ':',
minChars: 2,
@@ -62,7 +62,10 @@ export default {
immediate: true,
handler(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
this.$store.dispatch('inboxAssignableAgents/fetch', {
inboxIds: [inboxId],
includeAgentBots: true,
});
}
},
},
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
@@ -57,6 +58,10 @@ const showMetaSection = computed(() => {
);
});
const isAgentBotAssignee = computed(
() => props.chat?.meta?.assignee_type === 'AgentBot'
);
const hasSlaPolicyId = computed(
() => props.chat?.applied_sla?.id && !props.currentContact?.blocked
);
@@ -159,10 +164,15 @@ watch(
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center gap-px truncate"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
<Icon
:icon="
isAgentBotAssignee ? 'i-lucide-bot' : 'i-lucide-user-round'
"
class="size-3 text-n-slate-11 flex-shrink-0"
/>
<span class="truncate">{{ assignee.name }}</span>
</span>
<CardPriorityIcon
:priority="chat.priority"
@@ -123,7 +123,13 @@ export default {
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_CONTACT')
}}</label>
</div>
<div v-if="currentChat.meta.assignee" class="flex items-center gap-2">
<div
v-if="
currentChat.meta.assignee &&
currentChat.meta.assignee_type !== 'AgentBot'
"
class="flex items-center gap-2"
>
<input
id="assignee"
v-model="selectedType"
@@ -33,7 +33,9 @@ import {
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import wootConstants, {
META_RESTRICTION_STATUS_URL,
} from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -93,6 +95,7 @@ export default {
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
@@ -170,6 +173,12 @@ export default {
instagramInbox
);
},
isInstagramRestrictionBannerVisible() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
instagramRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
@@ -455,7 +464,15 @@ export default {
>
<div ref="topBannerRef">
<Banner
v-if="!currentChat.can_reply"
v-if="isInstagramRestrictionBannerVisible"
color-scheme="warning"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
:href-link="instagramRestrictionStatusUrl"
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
/>
<Banner
v-else-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
@@ -48,6 +48,8 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
getAgentVariables,
getContactVariables,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -393,7 +395,13 @@ export default {
contact: this.currentContact,
inbox: this.inbox,
});
return variables;
// Match the backend drops: names are Ruby-capitalized and
// {{agent.*}} is the message sender, not the assignee.
return {
...variables,
...getContactVariables(this.currentContact),
...getAgentVariables(this.currentUser),
};
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
@@ -33,10 +33,19 @@ defineProps({
v-if="variant === 'agent'"
:name="option.label"
:src="option.thumbnail"
:icon-name="option.iconName"
:status="option.status === 'online' ? option.status : null"
:size="20"
class="flex-shrink-0"
/>
>
<template v-if="option.iconName && option.thumbnail" #badge>
<div
class="absolute z-20 flex items-center justify-center rounded-full outline outline-1 outline-n-weak bg-n-solid-1 -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3"
>
<Icon icon="i-lucide-bot" class="text-n-slate-11 size-2" />
</div>
</template>
</Avatar>
<p class="menu-label truncate min-w-0 flex-1">
{{ option.label }}
</p>
@@ -26,11 +26,12 @@ const mockNoneAgent = {
};
const mockUseMapGetter = (overrides = {}) => {
const getAssignableAgents = vi.fn(() => allAgentsData);
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
'inboxAssignableAgents/getAssignableAgents': ref(getAssignableAgents),
};
const mergedGetters = { ...defaultGetters, ...overrides };
@@ -53,6 +54,24 @@ describe('useAgentsList', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
expect(
useMapGetter('inboxAssignableAgents/getAssignableAgents').value
).toHaveBeenCalledWith(1, { includeAgentBots: false });
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
);
});
it('requests agent bots when explicitly included', () => {
const { agentsList, assignableAgents } = useAgentsList(true, {
includeAgentBots: true,
});
expect(assignableAgents.value).toEqual(allAgentsData);
expect(
useMapGetter('inboxAssignableAgents/getAssignableAgents').value
).toHaveBeenCalledWith(1, { includeAgentBots: true });
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
@@ -10,9 +10,14 @@ import {
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @param {Object} [options] - Options for the assignable agents list.
* @param {boolean} [options.includeAgentBots=false] - Whether to include AgentBot assignees. Only pass this from surfaces that thread `assignee_type` through the assignment request.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(includeNoneAgent = true) {
export function useAgentsList(
includeNoneAgent = true,
{ includeAgentBots = false } = {}
) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
@@ -39,7 +44,9 @@ export function useAgentsList(includeNoneAgent = true) {
* @type {import('vue').ComputedRef<Array>}
*/
const assignableAgents = computed(() => {
return inboxId.value ? assignable.value(inboxId.value) : [];
return inboxId.value
? assignable.value(inboxId.value, { includeAgentBots })
: [];
});
/**
@@ -0,0 +1,35 @@
// Single source of truth for billing currencies on the frontend.
// Adding a currency = one entry in BILLING_CURRENCY_CONFIG, add the code to
// SUPPORTED_BILLING_CURRENCIES, and add its label key under
// BILLING_SETTINGS.CURRENCY.OPTIONS in the locale files.
export const DEFAULT_BILLING_CURRENCY = 'usd';
// Order here drives the order of the currency toggle in the UI.
export const SUPPORTED_BILLING_CURRENCIES = ['usd', 'brl'];
export const BILLING_CURRENCY_CONFIG = {
usd: {
code: 'usd',
intlLocale: 'en-US',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.USD',
},
brl: {
code: 'brl',
intlLocale: 'pt-BR',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.BRL',
},
};
export const getCurrencyConfig = code =>
BILLING_CURRENCY_CONFIG[(code || DEFAULT_BILLING_CURRENCY).toLowerCase()] ||
BILLING_CURRENCY_CONFIG[DEFAULT_BILLING_CURRENCY];
export const formatCurrencyAmount = (amount, code, options = {}) => {
const { intlLocale, code: currencyCode } = getCurrencyConfig(code);
return new Intl.NumberFormat(intlLocale, {
style: 'currency',
currency: currencyCode.toUpperCase(),
...options,
}).format(amount);
};
@@ -78,3 +78,5 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
export const META_RESTRICTION_STATUS_URL =
'https://status.chatwoot.com/incident/948346';
+1
View File
@@ -47,6 +47,7 @@ export const FEATURE_FLAGS = {
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
UNREAD_COUNT_FOR_FILTERS: 'unread_count_for_filters',
};
export const PREMIUM_FEATURES = [
@@ -17,6 +17,13 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
const MENTION_UNREAD_COUNTS_REFETCH_DELAY_MS =
UNREAD_COUNTS_REFETCH_THROTTLE_MS;
const getFilteredUnreadCountsRefreshRetryDelay = () =>
FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
@@ -25,6 +32,9 @@ class ActionCableConnector extends BaseActionCableConnector {
this.CancelTyping = [];
this.lastUnreadCountsFetchAt = null;
this.unreadCountsFetchTimer = null;
this.mentionUnreadCountsFetchTimer = null;
this.mentionUnreadCountsRetryTimer = null;
this.filteredUnreadCountsRetryTimer = null;
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
@@ -140,7 +150,12 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onConversationUnreadCountChanged = () => {
this.refreshConversationUnreadCountsWithFilteredRetry();
};
refreshConversationUnreadCountsWithFilteredRetry = () => {
this.throttledFetchConversationUnreadCounts();
this.scheduleFilteredUnreadCountsRetry();
};
throttledFetchConversationUnreadCounts = () => {
@@ -171,6 +186,51 @@ class ActionCableConnector extends BaseActionCableConnector {
this.unreadCountsFetchTimer = null;
};
scheduleMentionUnreadCountsFetch = () => {
if (!this.isFilteredUnreadCountsEnabled()) return;
// Mention invalidation runs through the async dispatcher, and stale snapshots
// can be served until the filtered-count backend refresh window opens.
this.scheduleUnreadCountsFetchAfter(
'mentionUnreadCountsFetchTimer',
MENTION_UNREAD_COUNTS_REFETCH_DELAY_MS
);
this.scheduleUnreadCountsFetchAfter(
'mentionUnreadCountsRetryTimer',
getFilteredUnreadCountsRefreshRetryDelay(),
{ reset: true }
);
};
scheduleFilteredUnreadCountsRetry = () => {
if (!this.isFilteredUnreadCountsEnabled()) return;
// Filtered snapshots can intentionally stay stale until the backend
// refresh window opens.
this.scheduleUnreadCountsFetchAfter(
'filteredUnreadCountsRetryTimer',
getFilteredUnreadCountsRefreshRetryDelay(),
{ reset: true }
);
};
scheduleUnreadCountsFetchAfter = (
timerName,
delay,
{ reset = false } = {}
) => {
if (this[timerName]) {
if (!reset) return;
clearTimeout(this[timerName]);
}
this[timerName] = setTimeout(() => {
this[timerName] = null;
this.throttledFetchConversationUnreadCounts();
}, delay);
};
fetchConversationUnreadCounts = () => {
if (!this.isConversationUnreadCountsEnabled()) return;
@@ -189,6 +249,17 @@ class ActionCableConnector extends BaseActionCableConnector {
);
};
isFilteredUnreadCountsEnabled = () => {
const accountId = this.app.$store.getters.getCurrentAccountId;
const isFeatureEnabled =
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
return (
isFeatureEnabled?.(accountId, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
isFeatureEnabled?.(accountId, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS)
);
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
@@ -212,6 +283,7 @@ class ActionCableConnector extends BaseActionCableConnector {
onConversationMentioned = data => {
this.app.$store.dispatch('addMentions', data);
this.scheduleMentionUnreadCountsFetch();
};
clearTimer = conversationId => {
@@ -273,6 +345,12 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
if (this.isFilteredUnreadCountsEnabled()) {
// Inbox/team/label visibility changes can change the accessible set used
// by filtered unread counts even when no conversation row changes.
this.refreshConversationUnreadCountsWithFilteredRetry();
}
};
onVoiceCallIncoming = data => {
@@ -38,7 +38,7 @@ export const getAgentsByUpdatedPresence = (
currentAccountId
) => {
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.id === currentUser.id
item.id === currentUser.id && (item.assignee_type || 'User') === 'User'
? {
...item,
availability_status: currentUser.accounts.find(
@@ -9,6 +9,7 @@ import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { InputRule, inputRules } from 'prosemirror-inputrules';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
@@ -428,6 +429,55 @@ export function stripUnsupportedFormatting(content, schema) {
* - emoji
*/
// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send.
const LIQUID_SYNTAX = /\{\{|\{%/;
// Value when set (and not itself Liquid), else the {{placeholder}} for the backend.
export const resolveVariableText = (key, variables) => {
const value = String(variables?.[key] ?? '');
return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`;
};
// Name variables normalized like the backend drops (UserDrop/ContactDrop):
// name split on whitespace, each word Ruby-capitalized (rest downcased).
const getNameVariables = (prefix, name) => {
const names = (name || '')
.split(/\s+/)
.filter(Boolean)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
return {
[`${prefix}.name`]: names.join(' '),
[`${prefix}.first_name`]: names[0] || '',
[`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '',
};
};
// {{agent.*}} values for the message sender.
export const getAgentVariables = user => ({
...getNameVariables('agent', user.name),
'agent.email': user.email,
});
// {{contact.*}} name values.
export const getContactVariables = contact =>
getNameVariables('contact', contact?.name);
// Resolves a manually typed {{variable}} to its value on the closing braces.
// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note.
export const createVariableInputRule = ({ isPrivate, getVariables }) => {
const rule = new InputRule(
/\{\{([^{}]+)\}\}$/,
(editorState, match, from, to) => {
if (isPrivate()) return null;
const [, key] = match;
const text = resolveVariableText(key, getVariables());
if (text === `{{${key}}}`) return null;
return editorState.tr.insertText(text, from, to);
}
);
return inputRules({ rules: [rule] });
};
/**
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
* @param {Object} editorView - The editor view instance.
@@ -462,7 +512,7 @@ const createNode = (editorView, nodeType, content) => {
);
}
case 'variable':
return state.schema.text(`{{${content}}}`);
return state.schema.text(content);
case 'emoji':
return state.schema.text(content);
case 'tool': {
@@ -497,8 +547,12 @@ const nodeCreators = {
to,
};
},
variable: (editorView, content, from, to) => ({
node: createNode(editorView, 'variable', content),
variable: (editorView, content, from, to, variables) => ({
node: createNode(
editorView,
'variable',
resolveVariableText(content, variables)
),
from,
to,
}),
@@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain: 'https://chwt.app/captain-docs',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
@@ -20,6 +20,8 @@ export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
@@ -1,5 +1,6 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
@@ -17,6 +18,9 @@ global.chatwootConfig = {
websocketURL: 'wss://test.chatwoot.com',
};
const mockRetryJitter = value =>
vi.spyOn(Math, 'random').mockReturnValue(value);
describe('ActionCableConnector - Copilot Tests', () => {
let store;
let actionCable;
@@ -39,6 +43,8 @@ describe('ActionCableConnector - Copilot Tests', () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
describe('copilot event handlers', () => {
@@ -81,12 +87,223 @@ describe('ActionCableConnector - Copilot Tests', () => {
});
it('should refetch unread counts when unread count changes', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0.5);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
vi.advanceTimersByTime(37499);
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenLastCalledWith(
'conversationUnreadCounts/get'
);
});
it('does not retry unread count changes when filtered counts are disabled', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockImplementation(
(_, featureFlag) =>
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(45000);
expect(mockDispatch).toHaveBeenCalledTimes(1);
});
it('delays unread count refetch when a conversation is mentioned', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const conversation = { id: 1, account_id: 1 };
actionCable.onReceived({
event: 'conversation.mentioned',
data: conversation,
});
expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(4999);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(1);
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
});
it('does not schedule mention unread count fetches when filtered counts are disabled', () => {
vi.useFakeTimers();
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockImplementation(
(_, featureFlag) =>
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
const conversation = { id: 1, account_id: 1 };
actionCable.onReceived({
event: 'conversation.mentioned',
data: conversation,
});
expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
vi.advanceTimersByTime(45000);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('retries mentioned unread counts after the backend refresh window', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0.5);
actionCable.onReceived({
event: 'conversation.mentioned',
data: { id: 1, account_id: 1 },
});
const unreadCountFetches = () =>
mockDispatch.mock.calls.filter(
([action]) => action === 'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(5000);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(32499);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(unreadCountFetches()).toHaveLength(2);
});
it('reschedules mentioned unread count retries for later invalidations', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0);
const unreadCountFetches = () =>
mockDispatch.mock.calls.filter(
([action]) => action === 'conversationUnreadCounts/get'
);
actionCable.onReceived({
event: 'conversation.mentioned',
data: { id: 1, account_id: 1 },
});
vi.advanceTimersByTime(5000);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(10000);
actionCable.onReceived({
event: 'conversation.mentioned',
data: { id: 1, account_id: 1 },
});
vi.advanceTimersByTime(5000);
expect(unreadCountFetches()).toHaveLength(2);
vi.advanceTimersByTime(10000);
expect(unreadCountFetches()).toHaveLength(2);
vi.advanceTimersByTime(15000);
expect(unreadCountFetches()).toHaveLength(3);
});
it('refetches filtered unread counts after account cache invalidation', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0.5);
const cacheKeys = {
label: 'label-key',
inbox: 'inbox-key',
team: 'team-key',
};
const unreadCountFetches = () =>
mockDispatch.mock.calls.filter(
([action]) => action === 'conversationUnreadCounts/get'
);
actionCable.onReceived({
event: 'account.cache_invalidated',
data: { account_id: 1, cache_keys: cacheKeys },
});
expect(mockDispatch).toHaveBeenCalledWith('labels/revalidate', {
newKey: cacheKeys.label,
});
expect(mockDispatch).toHaveBeenCalledWith('inboxes/revalidate', {
newKey: cacheKeys.inbox,
});
expect(mockDispatch).toHaveBeenCalledWith('teams/revalidate', {
newKey: cacheKeys.team,
});
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(37499);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(unreadCountFetches()).toHaveLength(2);
});
it('does not refetch unread counts after cache invalidation when filtered counts are disabled', () => {
vi.useFakeTimers();
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockImplementation(
(_, featureFlag) =>
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
actionCable.onReceived({
event: 'account.cache_invalidated',
data: {
account_id: 1,
cache_keys: {
label: 'label-key',
inbox: 'inbox-key',
team: 'team-key',
},
},
});
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(45000);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('does not refetch unread counts when unread count feature is disabled', () => {
@@ -94,16 +94,56 @@ describe('getContentNode', () => {
});
describe('getVariableNode', () => {
it('should create a variable node', () => {
const content = 'name';
const from = 0;
const to = 10;
getContentNode(editorView, 'variable', content, {
from,
to,
});
it('should render the resolved value directly when the variable has a value', () => {
getContentNode(
editorView,
'variable',
'contact.name',
{ from: 0, to: 10 },
{ 'contact.name': 'John' }
);
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
expect(editorView.state.schema.text).toHaveBeenCalledWith('John');
});
it('should resolve camelCase custom attributes and non-string values', () => {
getContentNode(
editorView,
'variable',
'contact.custom_attribute.cloudCustomer',
{ from: 0, to: 10 },
{ 'contact.custom_attribute.cloudCustomer': true }
);
expect(editorView.state.schema.text).toHaveBeenCalledWith('true');
});
it('should keep the placeholder when the variable has no value', () => {
getContentNode(
editorView,
'variable',
'contact.email',
{ from: 0, to: 10 },
{}
);
expect(editorView.state.schema.text).toHaveBeenCalledWith(
'{{contact.email}}'
);
});
it('should keep the placeholder when the value contains Liquid syntax', () => {
getContentNode(
editorView,
'variable',
'contact.name',
{ from: 0, to: 10 },
{ 'contact.name': '{{agent.email}}' }
);
expect(editorView.state.schema.text).toHaveBeenCalledWith(
'{{contact.name}}'
);
});
});
@@ -11,9 +11,12 @@ import {
calculateMenuPosition,
cleanSignature,
collapseSelection,
createVariableInputRule,
extractTextFromMarkdown,
findNodeToInsertImage,
findSignatureInBody,
getAgentVariables,
getContactVariables,
getContentNode,
getFormattingForEditor,
getMenuAnchor,
@@ -1228,3 +1231,149 @@ describe('Menu positioning helpers', () => {
});
});
});
describe('getAgentVariables', () => {
it('builds agent variables from the user', () => {
expect(
getAgentVariables({ name: 'John Doe', email: 'john@example.com' })
).toEqual({
'agent.name': 'John Doe',
'agent.first_name': 'John',
'agent.last_name': 'Doe',
'agent.email': 'john@example.com',
});
});
it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => {
const variables = getAgentVariables({ name: 'JANE doE' });
expect(variables['agent.name']).toBe('Jane Doe');
expect(variables['agent.first_name']).toBe('Jane');
expect(variables['agent.last_name']).toBe('Doe');
});
it('ignores extra whitespace between words', () => {
expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe(
'John Doe'
);
});
it('leaves last_name empty for single-word names', () => {
const variables = getAgentVariables({ name: 'john' });
expect(variables['agent.first_name']).toBe('John');
expect(variables['agent.last_name']).toBe('');
});
it('handles a missing name', () => {
const variables = getAgentVariables({ email: 'john@example.com' });
expect(variables['agent.name']).toBe('');
expect(variables['agent.first_name']).toBe('');
expect(variables['agent.last_name']).toBe('');
});
});
describe('getContactVariables', () => {
it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => {
expect(getContactVariables({ name: 'JANE doE' })).toEqual({
'contact.name': 'Jane Doe',
'contact.first_name': 'Jane',
'contact.last_name': 'Doe',
});
});
it('leaves last_name empty for single-word names', () => {
const variables = getContactVariables({ name: 'john' });
expect(variables['contact.first_name']).toBe('John');
expect(variables['contact.last_name']).toBe('');
});
it('handles a missing contact', () => {
expect(getContactVariables(undefined)['contact.name']).toBe('');
});
});
describe('createVariableInputRule', () => {
// Editor holding `{{key}` so we can simulate typing the final `}`.
const buildView = (typed, { isPrivate = false, variables = {} } = {}) => {
const plugin = createVariableInputRule({
isPrivate: () => isPrivate,
getVariables: () => variables,
});
const state = EditorState.create({
schema,
doc: schema.node('doc', null, [
schema.node('paragraph', null, [schema.text(typed)]),
]),
plugins: [plugin],
});
return new EditorView(document.body, { state });
};
// Types the closing `}`; when the rule declines, insert it like the browser would.
const typeClosingBrace = view => {
const end = view.state.doc.content.size - 1;
const handled = view.someProp('handleTextInput', fn =>
fn(view, end, end, '}')
);
if (!handled) {
view.dispatch(view.state.tr.insertText('}', end, end));
}
};
it('resolves a manually typed {{variable}} to its value on the closing brace', () => {
const view = buildView('{{contact.name}', {
variables: { 'contact.name': 'John' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('John');
view.destroy();
});
it('resolves boolean/non-string values', () => {
const view = buildView('{{contact.custom_attribute.cloudCustomer}', {
variables: { 'contact.custom_attribute.cloudCustomer': true },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('true');
view.destroy();
});
it('keeps the placeholder when the variable has no value', () => {
const view = buildView('{{contact.email}', { variables: {} });
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.email}}');
view.destroy();
});
it('keeps the placeholder when the value itself contains Liquid syntax', () => {
const view = buildView('{{contact.name}', {
variables: { 'contact.name': '{{agent.email}}' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.name}}');
view.destroy();
});
it('does not resolve inside a private note', () => {
const view = buildView('{{contact.name}', {
isPrivate: true,
variables: { 'contact.name': 'John' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.name}}');
view.destroy();
});
});
@@ -148,7 +148,7 @@ describe('#normalizeSidebarSortPreferences', () => {
it('falls back to defaults for unsupported preferences', () => {
const preferences = normalizeSidebarSortPreferences({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
[SIDEBAR_SORT_SECTIONS.FOLDERS]: 'unsupported_sort',
});
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
@@ -171,6 +171,15 @@ describe('#getSidebarSortOptions', () => {
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
});
it('keeps folder unread count options when filtered unread counts are enabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.FOLDERS, {
hasUnreadCounts: true,
});
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
});
it('removes unread count options when unread counts are disabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: false,
@@ -180,6 +189,16 @@ describe('#getSidebarSortOptions', () => {
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
it('removes folder unread count options when filtered unread counts are disabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.FOLDERS, {
hasUnreadCounts: false,
});
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
describe('#resolveSidebarSort', () => {
@@ -202,4 +221,14 @@ describe('#resolveSidebarSort', () => {
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
it('falls back to alphabetical sort for folders when filtered unread counts are disabled', () => {
const sortBy = resolveSidebarSort(
SIDEBAR_SORT_SECTIONS.FOLDERS,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
{ hasUnreadCounts: false }
);
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
@@ -44,6 +44,8 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You wont be able to send messages from this conversation anymore.",
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -58,7 +58,10 @@
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore."
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore.",
"RESTRICTED_WARNING": "Instagram inbox creation is temporarily unavailable due to current Instagram platform restrictions. Well restore support as soon as possible.",
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"STATUS_LINK": "View status update"
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
@@ -320,6 +323,8 @@
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow",
"RESTRICTED_WARNING": "WhatsApp embedded signup is temporarily unavailable due to current Meta platform restrictions. Well restore support as soon as possible.",
"STATUS_LINK": "View status update",
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
@@ -392,6 +392,82 @@
"CAPTAIN": {
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
"OVERVIEW": {
"HEADER": "Overview",
"WELCOME": {
"LABEL": "Captain summary",
"LOADING": "Generating summary…"
},
"INBOX_BANNER": {
"TEXT": "This assistant isn't connected to any inbox yet, so it won't respond to conversations.",
"ACTION": "Connect inbox",
"DISMISS": "Dismiss"
},
"COVERAGE_BANNER": {
"TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
"ACTION": "Review FAQs",
"DISMISS": "Dismiss"
},
"RANGES": {
"LAST_DAYS": "Last {count} days",
"THIS_MONTH": "This month",
"LAST_MONTH": "Last month"
},
"METRICS": {
"HANDLED": {
"LABEL": "Conversations handled",
"HINT": "Distinct conversations this assistant replied in."
},
"AUTO_RESOLUTION": {
"LABEL": "Auto-resolution rate",
"HINT": "Share of handled conversations closed without a human reply."
},
"HANDOFF": {
"LABEL": "Handoff rate",
"HINT": "Share of handled conversations escalated to a human agent."
},
"HOURS_SAVED": {
"LABEL": "Time saved",
"HINT": "Estimate: Captain replies times ~2 minutes of assumed agent effort per reply. Directional, not measured labor."
},
"REOPEN": {
"LABEL": "Reopen rate",
"HINT": "Auto-resolved conversations that were reopened afterwards."
},
"DEPTH": {
"LABEL": "Messages / conversation",
"HINT": "Average replies the assistant sends per conversation."
}
},
"DRILLDOWN": {
"CLOSE": "Close details",
"EMPTY": "No records found for this metric.",
"ERROR": "Could not load records. Please try again.",
"LOAD_MORE": "Load more",
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations"
},
"KNOWLEDGE": {
"TITLE": "Knowledge coverage",
"COVERAGE": "{pct}% approved",
"APPROVED": "Approved FAQs",
"PENDING": "Pending FAQs",
"DOCUMENTS": "Documents"
},
"LINKS": {
"DOCS": {
"TITLE": "Captain docs",
"DESCRIPTION": "Guides and how-tos for Captain"
},
"PLAYGROUND": {
"TITLE": "Playground",
"DESCRIPTION": "Test this assistant's replies"
},
"BILLING": {
"TITLE": "Billing",
"DESCRIPTION": "Manage credits and plan"
}
}
},
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
@@ -263,7 +263,7 @@
"EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.",
"ACCOUNT_SUSPENDED": {
"TITLE": "Account Suspended",
"MESSAGE": "Your account is suspended. Please reach out to the support team for more information."
"MESSAGE": "Your account has been suspended due to activity that may violate our policies. If you believe this is a mistake, please contact our support team."
},
"NO_ACCOUNTS": {
"TITLE": "No account found",
@@ -324,6 +324,7 @@
"ALL_COMPANIES": "All Companies",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_OVERVIEW": "Overview",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
@@ -467,7 +468,18 @@
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
"SEAT_COUNT": "Number of seats",
"RENEWS_ON": "Renews on"
"RENEWS_ON": "Renews on",
"CURRENCY": "Currency"
},
"CURRENCY": {
"SELECT": {
"TITLE": "Choose your billing currency",
"DESCRIPTION": "Select the currency you'd like to be billed in. This can't be changed once your subscription is created."
},
"OPTIONS": {
"USD": "US Dollar (USD)",
"BRL": "Brazilian Real (BRL)"
}
},
"VIEW_PRICING": "View Pricing",
"MANAGE_SUBSCRIPTION": {
@@ -503,6 +515,7 @@
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"RETRY": "Retry",
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
@@ -529,7 +542,7 @@
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
"PLACEHOLDER": "https://sso.example.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
@@ -549,7 +562,7 @@
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
"PLACEHOLDER": "https://sso.example.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
@@ -501,7 +501,7 @@
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "A URL para onde as solicitações de autenticação SAML serão enviadas",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
"PLACEHOLDER": "https://sso.example.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Certificado de assinatura no formato PEM",
@@ -521,7 +521,7 @@
"IDP_ENTITY_ID": {
"LABEL": "ID da Entidade do Provedor de Identidade",
"HELP": "Identificador exclusivo do seu provedor de identidade (geralmente encontrado na configuração do IdP)",
"PLACEHOLDER": "https://seu-idp.com/saml"
"PLACEHOLDER": "https://sso.example.com/saml"
},
"UPDATE_BUTTON": "Atualizar configurações de SAML",
"API": {
@@ -0,0 +1,194 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { usePolicy } from 'dashboard/composables/usePolicy';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
import AssistantDrilldownDrawer from 'dashboard/components-next/captain/pageComponents/overview/AssistantDrilldownDrawer.vue';
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
import CoverageBanner from 'dashboard/components-next/captain/pageComponents/overview/CoverageBanner.vue';
const { t } = useI18n();
const route = useRoute();
// Drilldown is admin-only; the backend policy enforces the same restriction.
const { checkPermissions } = usePolicy();
const canDrilldown = computed(() => checkPermissions(['administrator']));
const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId);
const stats = ref(null);
const fetchStats = async () => {
try {
const { data } = await CaptainAssistant.getStats({
assistantId: assistantId.value,
range: selectedRange.value,
});
stats.value = data;
} catch {
stats.value = null;
}
};
watch([selectedRange, assistantId], fetchStats, { immediate: true });
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
// neutral, so we can colour the delta independently of its sign.
const resolveTrendGood = (trendValue, direction) => {
if (direction === 'neutral' || trendValue === 0) return null;
return direction === 'up' ? trendValue > 0 : trendValue < 0;
};
// Trend units mirror the backend pack mode: a relative percent change ('%') for
// :percent metrics, a percentage-point delta (' pts') for rate metrics packed as
// :point, and a plain number for :absolute counts like conversation depth.
const TREND_SUFFIX = { percent: '%', point: ' pts', absolute: '' };
// Hours-saved is reported in hours, but large values read better as days. Past
// 100h we switch the unit so the card stays legible.
const formatDuration = hours =>
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const data = stats.value?.[statKey];
if (!data) return { value: '—', trend: '', trendGood: null };
const sign = data.trend > 0 ? '+' : '';
return {
value: formatValue(data.current),
trend: `${sign}${data.trend}${TREND_SUFFIX[trendKind]}`,
trendGood: resolveTrendGood(data.trend, direction),
};
};
const metrics = computed(() => [
{
key: 'handled',
metric: 'conversations_handled',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
},
{
key: 'autoResolution',
metric: 'auto_resolution_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
},
{
key: 'handoff',
metric: 'handoff_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'reopen',
metric: 'reopen_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'hoursSaved',
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
...metricFor('hours_saved', formatDuration, 'up'),
},
{
key: 'depth',
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.HINT'),
...metricFor(
'conversation_depth',
v => v.toFixed(1),
'neutral',
'absolute'
),
},
]);
const drilldown = ref({ metric: '', label: '', value: '' });
const isDrilldownOpen = ref(false);
const openDrilldown = metric => {
drilldown.value = {
metric: metric.metric,
label: metric.label,
value: metric.value,
};
isDrilldownOpen.value = true;
};
const closeDrilldown = () => {
isDrilldownOpen.value = false;
};
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.OVERVIEW.HEADER')"
:is-empty="false"
:show-pagination-footer="false"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
>
<template #headerActions>
<RangeSelector v-model="selectedRange" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<div class="flex flex-col gap-6 pb-8">
<InboxBanner />
<CoverageBanner :knowledge="stats?.knowledge" />
<WelcomeCard :range="selectedRange" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
>
<MetricCard
v-for="metric in metrics"
:key="metric.key"
:label="metric.label"
:value="metric.value"
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
:clickable="canDrilldown && Boolean(metric.metric)"
@click="openDrilldown(metric)"
/>
</div>
<KnowledgeCard :knowledge="stats?.knowledge" />
<QuickLinks />
</div>
<AssistantDrilldownDrawer
v-if="canDrilldown"
:open="isDrilldownOpen"
:assistant-id="assistantId"
:metric="drilldown.metric"
:metric-name="drilldown.label"
:metric-value="drilldown.value"
:range="selectedRange"
@close="closeDrilldown"
/>
</template>
</PageLayout>
</template>
@@ -6,6 +6,7 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
import AssistantOverviewIndex from './assistants/overview/Index.vue';
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
@@ -36,6 +37,12 @@ const metaV2 = {
};
const assistantRoutes = [
{
path: frontendURL('accounts/:accountId/captain/:assistantId/overview'),
component: AssistantOverviewIndex,
name: 'captain_assistants_overview_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
@@ -129,7 +136,7 @@ export const routes = [
return {
name: 'captain_assistants_index',
params: {
navigationPath: 'captain_assistants_responses_index',
navigationPath: 'captain_assistants_overview_index',
...to.params,
},
};
@@ -53,6 +53,7 @@ const routeToLastActiveAssistant = () => {
const { navigationPath } = route.params;
const isAValidRoute = [
'captain_assistants_overview_index', // Overview page
'captain_assistants_responses_index', // Faq page
'captain_assistants_documents_index', // Document page
'captain_assistants_scenarios_index', // Scenario page
@@ -64,7 +65,7 @@ const routeToLastActiveAssistant = () => {
const navigateTo = isAValidRoute
? navigationPath
: 'captain_assistants_responses_index';
: 'captain_assistants_overview_index';
return routeToView(navigateTo, {
accountId: route.params.accountId,
@@ -25,7 +25,7 @@ export default {
},
},
setup() {
const { agentsList } = useAgentsList();
const { agentsList } = useAgentsList(true, { includeAgentBots: true });
return {
agentsList,
};
@@ -81,18 +81,27 @@ export default {
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
const assignee = this.currentChat.meta.assignee;
return (
assignee && {
...assignee,
assignee_type: this.currentChat.meta.assignee_type || 'User',
}
);
},
set(agent) {
const agentId = agent ? agent.id : null;
const assigneeType = agent ? agent.assignee_type || 'User' : null;
this.$store.dispatch('setCurrentChatAssignee', {
conversationId: this.currentChat.id,
assignee: agent,
assigneeType,
});
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
assigneeType,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
@@ -152,7 +161,10 @@ export default {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
if (
this.assignedAgent.id !== this.currentUser.id ||
(this.assignedAgent.assignee_type || 'User') !== 'User'
) {
return true;
}
return false;
@@ -183,7 +195,11 @@ export default {
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
if (
this.assignedAgent?.id === selectedItem.id &&
(this.assignedAgent?.assignee_type || 'User') ===
(selectedItem.assignee_type || 'User')
) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
@@ -7,17 +7,20 @@ import { useMapGetter } from 'dashboard/composables/store';
// Mirrors the availability checks in ChannelItem.vue.
export function useChannelConfig() {
const globalConfig = useMapGetter('globalConfig/get');
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const installationConfig = window.chatwootConfig || {};
const CHANNEL_CONFIGURED = {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
!isOnChatwootCloud.value &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () => Boolean(installationConfig.instagramAppId),
instagram: () =>
!isOnChatwootCloud.value && Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
@@ -1,6 +1,7 @@
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import googleClient from 'dashboard/api/channel/googleClient';
@@ -23,11 +24,17 @@ export function useChannelConnect() {
const { t } = useI18n();
const store = useStore();
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const { isOnChatwootCloud } = useAccount();
const connectViaOAuth = async provider => {
const client = OAUTH_CLIENTS[provider];
if (!client) return;
if (provider === 'instagram' && isOnChatwootCloud.value) {
useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING'));
return;
}
try {
const {
data: { url },
@@ -11,9 +11,20 @@ vi.mock('vue-router');
// resolves the current account is exercised here too. The real ./constants are
// used, so assertions validate against the actual channel identity (label keys,
// channel_type, social ordering) derived from CHANNEL_LIST.
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
const mountComposable = ({
brandInfo,
inboxes = [],
isOnChatwootCloud = false,
} = {}) => {
const store = createStore({
modules: {
globalConfig: {
namespaced: true,
getters: {
get: () => ({}),
isOnChatwootCloud: () => isOnChatwootCloud,
},
},
accounts: {
namespaced: true,
getters: {
@@ -195,6 +206,22 @@ describe('useDetectedChannels', () => {
'line',
]);
});
it('hides Instagram from onboarding on Chatwoot Cloud', () => {
const { displayedChannels } = mountComposable({
isOnChatwootCloud: true,
brandInfo: {
socials: [
{ type: 'instagram', url: 'https://instagram.com/acme' },
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'tiktok',
]);
});
});
describe('remainingChannels', () => {
@@ -15,6 +15,8 @@ import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
import { getCurrencyConfig } from 'dashboard/constants/billing';
import { useI18n } from 'vue-i18n';
const router = useRouter();
const { currentAccount, isOnChatwootCloud } = useAccount();
@@ -29,6 +31,7 @@ const {
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const { t } = useI18n();
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
@@ -36,6 +39,10 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
const isWaitingForBilling = ref(false);
const purchaseCreditsModalRef = ref(null);
// Currency selection shown to new accounts whose locale supports a non-USD currency.
const currencySelectionRequired = ref(false);
const currencyOptions = ref([]);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
@@ -61,6 +68,13 @@ const subscribedQuantity = computed(() => {
return customAttributes.value.subscribed_quantity;
});
const billingCurrency = computed(() => {
if (!customAttributes.value.billing_currency) return '';
return t(
getCurrencyConfig(customAttributes.value.billing_currency).i18nLabelKey
);
});
const subscriptionRenewsOn = computed(() => {
if (!customAttributes.value.subscription_ends_on) return '';
const endDate = new Date(customAttributes.value.subscription_ends_on);
@@ -78,7 +92,9 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
await store.dispatch('accounts/subscription');
const data = await store.dispatch('accounts/subscription');
currencySelectionRequired.value = !!data?.currency_selection_required;
currencyOptions.value = data?.currency_options || [];
}
// Always fetch limits for billing page to show credit usage
fetchLimits();
@@ -97,6 +113,9 @@ const handleBillingPageLogic = async () => {
// If cloud user, fetch account details first
await fetchAccountDetails();
// Waiting on the user to pick a billing currency don't auto-refresh.
if (currencySelectionRequired.value) return;
// If still no billing plan after fetch
if (!hasABillingPlan.value) {
// If we haven't attempted refresh yet, do it once
@@ -118,6 +137,13 @@ const handleBillingPageLogic = async () => {
}
};
const onSelectCurrency = async code => {
await store.dispatch('accounts/selectBillingCurrency', code);
currencySelectionRequired.value = false;
// Currency stored and customer creation kicked off resume the standard wait flow.
await handleBillingPageLogic();
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
@@ -148,7 +174,9 @@ onMounted(handleBillingPageLogic);
? $t('BILLING_SETTINGS.NO_BILLING_USER')
: $t('ATTRIBUTES_MGMT.LOADING')
"
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
:no-records-found="
!hasABillingPlan && !isWaitingForBilling && !currencySelectionRequired
"
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
>
<template #header>
@@ -160,7 +188,30 @@ onMounted(handleBillingPageLogic);
/>
</template>
<template #body>
<section class="grid gap-4">
<section v-if="currencySelectionRequired" class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.CURRENCY.SELECT.TITLE')"
:description="$t('BILLING_SETTINGS.CURRENCY.SELECT.DESCRIPTION')"
>
<template #action>
<div class="flex gap-2">
<ButtonV4
v-for="code in currencyOptions"
:key="code"
sm
solid
blue
:is-loading="uiFlags.isCheckoutInProcess"
:disabled="uiFlags.isCheckoutInProcess"
@click="onSelectCurrency(code)"
>
{{ $t(getCurrencyConfig(code).i18nLabelKey) }}
</ButtonV4>
</div>
</template>
</BillingCard>
</section>
<section v-else class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
@@ -188,6 +239,11 @@ onMounted(handleBillingPageLogic);
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
:value="subscriptionRenewsOn"
/>
<DetailItem
v-if="billingCurrency"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.CURRENCY')"
:value="billingCurrency"
/>
</div>
</BillingCard>
<BillingCard
@@ -1,4 +1,6 @@
<script setup>
import { formatCurrencyAmount } from 'dashboard/constants/billing';
defineProps({
credits: {
type: Number,
@@ -33,11 +35,7 @@ const formatCredits = credits => {
};
const formatAmount = (amount, currency) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(amount);
return formatCurrencyAmount(amount, currency, { minimumFractionDigits: 0 });
};
</script>
@@ -4,20 +4,18 @@ import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
import {
formatCurrencyAmount,
DEFAULT_BILLING_CURRENCY,
} from 'dashboard/constants/billing';
const emit = defineEmits(['close', 'success']);
const emit = defineEmits(['success']);
const { t } = useI18n();
const TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12000, amount: 200.0, currency: 'usd' },
];
const POPULAR_CREDITS_AMOUNT = 6000;
const STEP_SELECT = 'select';
const STEP_CONFIRM = 'confirm';
@@ -27,16 +25,20 @@ const selectedCredits = ref(null);
const isLoading = ref(false);
const currentStep = ref(STEP_SELECT);
// Topup packages come from the backend for the account's billing currency.
const topupOptions = ref([]);
const optionsCurrency = ref(DEFAULT_BILLING_CURRENCY);
const isFetchingOptions = ref(false);
const fetchError = ref(false);
const selectedOption = computed(() => {
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
return topupOptions.value.find(o => o.credits === selectedCredits.value);
});
const formattedAmount = computed(() => {
if (!selectedOption.value) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: selectedOption.value.currency.toUpperCase(),
}).format(selectedOption.value.amount);
const { amount, currency } = selectedOption.value;
return formatCurrencyAmount(amount, currency || optionsCurrency.value);
});
const formattedCredits = computed(() => {
@@ -64,24 +66,44 @@ const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const open = () => {
const popularOption = TOPUP_OPTIONS.find(
const selectDefaultOption = () => {
const popularOption = topupOptions.value.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
selectedCredits.value =
popularOption?.credits || topupOptions.value[0]?.credits || null;
};
const fetchOptions = async () => {
isFetchingOptions.value = true;
fetchError.value = false;
try {
const { data } = await EnterpriseAccountAPI.getTopupOptions();
topupOptions.value = data.options ?? [];
optionsCurrency.value = (
data.currency || DEFAULT_BILLING_CURRENCY
).toLowerCase();
selectDefaultOption();
} catch {
fetchError.value = true;
topupOptions.value = [];
} finally {
isFetchingOptions.value = false;
}
};
const open = () => {
currentStep.value = STEP_SELECT;
isLoading.value = false;
selectedCredits.value = null;
dialogRef.value?.open();
fetchOptions();
};
const close = () => {
dialogRef.value?.close();
};
const handleClose = () => {
emit('close');
};
const goToConfirmStep = () => {
if (!selectedOption.value) return;
currentStep.value = STEP_CONFIRM;
@@ -127,32 +149,58 @@ defineExpose({ open, close });
:width="dialogWidth"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<!-- Step 1: Select Credits Package -->
<template v-if="currentStep === 'select'">
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
<template v-if="currentStep === STEP_SELECT">
<div
v-if="isFetchingOptions"
class="flex items-center justify-center gap-2 py-10"
>
<Spinner />
<span class="text-sm text-n-slate-11">{{
$t('BILLING_SETTINGS.TOPUP.LOADING')
}}</span>
</div>
<div
v-else-if="fetchError"
class="flex flex-col items-center justify-center gap-3 py-10"
>
<p class="text-sm text-n-slate-11">
{{ $t('BILLING_SETTINGS.TOPUP.FETCH_ERROR') }}
</p>
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.RETRY')"
@click="fetchOptions"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
<template v-else>
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in topupOptions"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
</template>
<!-- Step 2: Confirm Purchase -->
@@ -178,7 +226,7 @@ defineExpose({ open, close });
<template #footer>
<!-- Step 1 Footer -->
<div
v-if="currentStep === 'select'"
v-if="currentStep === STEP_SELECT"
class="flex items-center justify-between w-full gap-3"
>
<Button
@@ -192,7 +240,7 @@ defineExpose({ open, close });
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits"
:disabled="!selectedCredits || isFetchingOptions || fetchError"
@click="goToConfirmStep"
/>
</div>
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
@@ -42,9 +44,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
export default {
components: {
Banner,
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
@@ -76,6 +80,7 @@ export default {
AccountHealth,
Widget,
AccessToken,
Icon,
},
mixins: [inboxMixin],
setup() {
@@ -338,6 +343,12 @@ export default {
instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
showInstagramRestrictionSettingsBanner() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
metaRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required;
},
@@ -737,6 +748,29 @@ export default {
class="mx-6 mb-4"
:class="bannerMaxWidth"
/>
<Banner
v-if="showInstagramRestrictionSettingsBanner"
color="amber"
class="mx-6 mb-4 max-w-4xl"
>
<div class="flex items-start gap-3 text-start">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
<a
:href="metaRestrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div
v-if="selectedTabKey === 'inbox-settings'"
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import router from '../../../../index';
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
import InboxesAPI from 'dashboard/api/inboxes';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -12,6 +13,12 @@ export default {
components: {
NextButton,
},
props: {
enableCallingOnComplete: {
type: Boolean,
default: false,
},
},
setup() {
return { v$: useVuelidate() };
},
@@ -59,6 +66,14 @@ export default {
}
);
if (this.enableCallingOnComplete) {
try {
await InboxesAPI.enableWhatsappCalling(whatsappChannel.id);
} catch (_) {
useAlert(this.$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED'));
}
}
router.replace({
name: 'settings_inboxes_add_agents',
params: {
@@ -165,6 +180,7 @@ export default {
<div class="w-full mt-4">
<NextButton
:disabled="uiFlags.isCreating"
:is-loading="uiFlags.isCreating"
type="submit"
solid
@@ -1,15 +1,23 @@
<script setup>
import { ref, onMounted } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import instagramClient from 'dashboard/api/channel/instagramClient';
import Button from 'dashboard/components-next/button/Button.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const hasError = ref(false);
const errorStateMessage = ref('');
const errorStateDescription = ref('');
const isRequestingAuthorization = ref(false);
const isInstagramConnectionRestricted = computed(() => {
return isOnChatwootCloud.value;
});
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search);
@@ -56,7 +64,7 @@ const requestAuthorization = async () => {
</div>
<div
v-else
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
class="flex flex-col items-center justify-center w-full px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
@@ -68,11 +76,36 @@ const requestAuthorization = async () => {
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
lg
icon="i-ri-instagram-line"
:disabled="isRequestingAuthorization"
:disabled="
isRequestingAuthorization || isInstagramConnectionRestricted
"
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
@click="requestAuthorization()"
/>
<Banner
v-if="isInstagramConnectionRestricted"
color="amber"
class="w-full max-w-2xl mt-6"
>
<div class="flex items-start gap-3 text-left">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING') }}
<a
:href="META_RESTRICTION_STATUS_URL"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
</div>
</div>
</div>
@@ -7,10 +7,13 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
@@ -21,6 +24,10 @@ const PROVIDER_TYPES = {
THREE_SIXTY_DIALOG: '360dialog',
};
const isWhatsappEmbeddedSignupRestricted = computed(() => {
return isOnChatwootCloud.value;
});
const hasWhatsappAppId = computed(() => {
return (
window.chatwootConfig?.whatsappAppId &&
@@ -101,7 +108,11 @@ const handleManualLinkClick = () => {
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
"
>
<WhatsappEmbeddedSignup />
<WhatsappEmbeddedSignup
:is-disabled="isWhatsappEmbeddedSignupRestricted"
:show-restriction-alert="isWhatsappEmbeddedSignupRestricted"
:restriction-status-url="META_RESTRICTION_STATUS_URL"
/>
<!-- Manual setup fallback option -->
<div class="pt-6 mt-6 border-t border-n-weak">
@@ -1,11 +1,11 @@
<script setup>
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
</script>
<template>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<div class="px-6 py-5 rounded-2xl border border-n-weak">
<WhatsappEmbeddedSignup enable-calling-on-complete />
<CloudWhatsapp enable-calling-on-complete />
</div>
</div>
</template>
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import Banner from 'next/banner/Banner.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
@@ -17,6 +18,22 @@ const props = defineProps({
type: Boolean,
default: false,
},
isDisabled: {
type: Boolean,
default: false,
},
showRestrictionAlert: {
type: Boolean,
default: false,
},
restrictionStatusUrl: {
type: String,
default: '',
},
restrictionWarningText: {
type: String,
default: '',
},
});
const store = useStore();
@@ -81,6 +98,8 @@ const handleSignupSuccess = async inboxData => {
};
const launchEmbeddedSignup = async () => {
if (props.isDisabled) return;
let credentials;
try {
credentials = await runEmbeddedSignup();
@@ -174,9 +193,33 @@ const launchEmbeddedSignup = async () => {
</I18nT>
</div>
<Banner v-if="showRestrictionAlert" color="amber" class="w-full mb-6">
<div class="flex items-start gap-3 text-left">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{
restrictionWarningText ||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING')
}}
<a
v-if="restrictionStatusUrl"
:href="restrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div class="flex mt-4">
<NextButton
:disabled="isAuthenticating"
:disabled="isAuthenticating || isDisabled"
:is-loading="isAuthenticating"
faded
slate
@@ -1,7 +1,13 @@
import { computed, ref } from 'vue';
import ReportsAPI from 'dashboard/api/reports';
export function useReportDrilldown() {
// `fetcher` is any `({ ...request, page, signal }) => Promise` returning the
// shared drilldown envelope (`{ data: { meta, payload } }`), so the same paging
// and abort machinery backs both the reports and Captain assistant drilldowns.
// The default is wrapped so `ReportsAPI` stays the receiver when invoked.
export function useReportDrilldown(
fetcher = params => ReportsAPI.getDrilldown(params)
) {
const activeRequest = ref(null);
const records = ref([]);
const meta = ref({});
@@ -20,17 +26,7 @@ export function useReportDrilldown() {
const isCurrentRequest = token =>
token === requestToken && !!activeRequest.value;
const requestFingerprint = request =>
JSON.stringify({
metric: request.metric,
bucketTimestamp: request.bucketTimestamp,
from: request.from,
to: request.to,
type: request.type,
id: request.id,
groupBy: request.groupBy,
businessHours: request.businessHours,
});
const requestFingerprint = request => JSON.stringify(request);
const abortActiveRequest = () => {
if (!activeRequestController) return;
@@ -55,7 +51,7 @@ export function useReportDrilldown() {
hasError.value = false;
try {
const response = await ReportsAPI.getDrilldown({
const response = await fetcher({
...request,
page,
signal: controller.signal,
@@ -1,5 +1,6 @@
<script setup>
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { onMounted } from 'vue';
const toggleSupportWidgetVisibility = () => {
@@ -8,6 +9,12 @@ const toggleSupportWidgetVisibility = () => {
}
};
const toggleSupportWidget = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
const setupListenerForWidgetEvent = () => {
window.addEventListener('chatwoot:on-message', () => {
toggleSupportWidgetVisibility();
@@ -25,6 +32,14 @@ onMounted(() => {
<EmptyState
:title="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.TITLE')"
:message="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.MESSAGE')"
/>
>
<div class="flex justify-center">
<NextButton
icon="i-lucide-life-buoy"
:label="$t('SIDEBAR_ITEMS.CONTACT_SUPPORT')"
@click="toggleSupportWidget"
/>
</div>
</EmptyState>
</div>
</template>
@@ -144,7 +144,20 @@ export const actions = {
subscription: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
await EnterpriseAccountAPI.subscription();
const response = await EnterpriseAccountAPI.subscription();
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: false });
}
},
selectBillingCurrency: async ({ commit }, currency) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
await EnterpriseAccountAPI.selectBillingCurrency(currency);
} catch (error) {
throwErrorMessage(error);
} finally {
@@ -6,6 +6,10 @@ export const state = {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
};
const normalizeCount = count => {
@@ -37,6 +41,18 @@ export const getters = {
getTeamUnreadCount: $state => teamId => {
return $state.teams[String(teamId)] || 0;
},
getMentionsUnreadCount($state) {
return $state.mentionsCount;
},
getParticipatingUnreadCount($state) {
return $state.participatingCount;
},
getUnattendedUnreadCount($state) {
return $state.unattendedCount;
},
getFolderUnreadCount: $state => folderId => {
return $state.folders[String(folderId)] || 0;
},
getInboxUnreadCounts($state) {
return $state.inboxes;
},
@@ -46,6 +62,9 @@ export const getters = {
getTeamUnreadCounts($state) {
return $state.teams;
},
getFolderUnreadCounts($state) {
return $state.folders;
},
};
export const actions = {
@@ -68,6 +87,10 @@ export const mutations = {
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
$state.mentionsCount = normalizeCount(payload.mentions_count);
$state.participatingCount = normalizeCount(payload.participating_count);
$state.unattendedCount = normalizeCount(payload.unattended_count);
$state.folders = normalizeCounts(payload.folders);
},
};
@@ -1,8 +1,15 @@
import types from '../mutation-types';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ConversationInboxApi from '../../api/inbox/conversation';
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
const getFilteredUnreadCountsRefreshRetryDelay = () =>
FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
const state = {
records: {},
uiFlags: {
@@ -20,6 +27,43 @@ export const getters = {
},
};
const hasFeatureEnabled = (rootGetters, featureFlag) => {
const accountId = rootGetters?.getCurrentAccountId;
const isFeatureEnabled = rootGetters?.['accounts/isFeatureEnabledonAccount'];
return Boolean(accountId && isFeatureEnabled?.(accountId, featureFlag));
};
const hasCurrentUser = (participants, currentUserId) =>
(Array.isArray(participants) ? participants : []).some(
participant => participant.id === currentUserId
);
const refreshConversationUnreadCounts = dispatch => {
dispatch('conversationUnreadCounts/get', {}, { root: true });
setTimeout(
() => dispatch('conversationUnreadCounts/get', {}, { root: true }),
getFilteredUnreadCountsRefreshRetryDelay()
);
};
const shouldRefreshConversationUnreadCounts = (
{ rootGetters, state: moduleState },
conversationId,
participants
) => {
const currentUserId =
rootGetters?.getCurrentUserID || rootGetters?.getCurrentUser?.id;
return (
currentUserId &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS) &&
hasCurrentUser(moduleState.records[conversationId], currentUserId) !==
hasCurrentUser(participants, currentUserId)
);
};
export const actions = {
show: async ({ commit }, { conversationId }) => {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
@@ -42,7 +86,10 @@ export const actions = {
}
},
update: async ({ commit }, { conversationId, userIds }) => {
update: async (
{ commit, dispatch, rootGetters, state: moduleState },
{ conversationId, userIds }
) => {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
isUpdating: true,
});
@@ -52,10 +99,18 @@ export const actions = {
conversationId,
userIds,
});
const shouldRefreshUnreadCounts = shouldRefreshConversationUnreadCounts(
{ rootGetters, state: moduleState },
conversationId,
response.data
);
commit(types.SET_CONVERSATION_PARTICIPANTS, {
conversationId,
data: response.data,
});
if (shouldRefreshUnreadCounts) {
refreshConversationUnreadCounts(dispatch);
}
} catch (error) {
throwErrorMessage(error);
} finally {
@@ -208,23 +208,31 @@ const actions = {
}
},
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
assignAgent: async (
{ dispatch },
{ conversationId, agentId, assigneeType }
) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
assigneeType,
});
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
assigneeType,
});
} catch (error) {
// Handle error
}
},
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
commit(types.ASSIGN_AGENT, { conversationId, assignee });
setCurrentChatAssignee(
{ commit },
{ conversationId, assignee, assigneeType }
) {
commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
},
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
@@ -108,10 +108,11 @@ export const mutations = {
}
},
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
[types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
chat.meta.assignee_type = assigneeType;
}
},
@@ -1,11 +1,17 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import CustomViewsAPI from '../../api/customViews';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const VIEW_TYPES = {
CONVERSATION: 'conversation',
CONTACT: 'contact',
};
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
const getFilteredUnreadCountsRefreshRetryDelay = () =>
FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
// use to normalize the filter type
const FILTER_KEYS = {
@@ -21,6 +27,38 @@ const getFolderContactId = folder =>
folder?.query?.payload?.find(filter => filter.attribute_key === 'contact_id')
?.values?.[0];
const hasFeatureEnabled = (rootGetters, featureFlag) => {
const accountId = rootGetters?.getCurrentAccountId;
const isFeatureEnabled = rootGetters?.['accounts/isFeatureEnabledonAccount'];
return Boolean(accountId && isFeatureEnabled?.(accountId, featureFlag));
};
const shouldRefreshConversationUnreadCounts = (filterType, rootGetters) => {
return (
FILTER_KEYS[filterType] === VIEW_TYPES.CONVERSATION &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS)
);
};
const dispatchConversationUnreadCounts = dispatch => {
dispatch('conversationUnreadCounts/get', {}, { root: true });
};
const refreshConversationUnreadCounts = (
{ dispatch, rootGetters },
filterType
) => {
if (!shouldRefreshConversationUnreadCounts(filterType, rootGetters)) return;
dispatchConversationUnreadCounts(dispatch);
setTimeout(
() => dispatchConversationUnreadCounts(dispatch),
getFilteredUnreadCountsRefreshRetryDelay()
);
};
export const state = {
[VIEW_TYPES.CONVERSATION]: {
records: [],
@@ -71,14 +109,19 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: false });
}
},
create: async function createCustomViews({ commit }, obj) {
create: async function createCustomViews(
{ commit, dispatch, rootGetters },
obj
) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.create(obj);
const filterType = FILTER_KEYS[obj.filter_type];
commit(types.ADD_CUSTOM_VIEW, {
data: response.data,
filterType: FILTER_KEYS[obj.filter_type],
filterType,
});
refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
return response;
} catch (error) {
const errorMessage = error?.response?.data?.message;
@@ -87,14 +130,19 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
update: async function updateCustomViews({ commit }, obj) {
update: async function updateCustomViews(
{ commit, dispatch, rootGetters },
obj
) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.update(obj.id, obj);
const filterType = FILTER_KEYS[obj.filter_type];
commit(types.UPDATE_CUSTOM_VIEW, {
data: response.data,
filterType: FILTER_KEYS[obj.filter_type],
filterType,
});
refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
@@ -102,11 +150,12 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
delete: async ({ commit }, { id, filterType }) => {
delete: async ({ commit, dispatch, rootGetters }, { id, filterType }) => {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true });
try {
await CustomViewsAPI.deleteCustomViews(id, filterType);
commit(types.DELETE_CUSTOM_VIEW, { data: id, filterType });
refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
} catch (error) {
throw new Error(error);
} finally {
@@ -7,31 +7,52 @@ const state = {
},
};
const recordKey = (inboxId, { includeAgentBots = false } = {}) =>
includeAgentBots ? `${inboxId}:with_agent_bots` : inboxId;
export const types = {
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
};
export const getters = {
getAssignableAgents: $state => inboxId => {
const allAgents = $state.records[inboxId] || [];
const verifiedAgents = allAgents.filter(record => record.confirmed);
return verifiedAgents;
},
getAssignableAgents:
$state =>
(inboxId, options = {}) => {
const includeAgentBots = options.includeAgentBots || false;
const allAgents = $state.records[recordKey(inboxId, options)] || [];
const verifiedAgents = allAgents.filter(
record =>
record.confirmed ||
(includeAgentBots && record.assignee_type === 'AgentBot')
);
return verifiedAgents;
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
async fetch({ commit }, inboxIds) {
async fetch({ commit }, actionPayload) {
const inboxIds = Array.isArray(actionPayload)
? actionPayload
: actionPayload.inboxIds;
const includeAgentBots =
!Array.isArray(actionPayload) && actionPayload.includeAgentBots;
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true });
try {
const {
data: { payload },
} = await AssignableAgentsAPI.get(inboxIds);
} = await AssignableAgentsAPI.get(inboxIds, { includeAgentBots });
if (includeAgentBots) {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: inboxIds.join(','),
members: payload,
});
}
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: inboxIds.join(','),
inboxId: recordKey(inboxIds.join(','), { includeAgentBots }),
members: payload,
});
} catch (error) {
@@ -19,6 +19,10 @@ describe('#actions', () => {
inboxes: { 1: '2' },
labels: { 3: 4 },
teams: { 5: 6 },
mentions_count: 7,
participating_count: 8,
unattended_count: 9,
folders: { 10: 11 },
};
axios.get.mockResolvedValue({ data: { payload } });
@@ -7,6 +7,7 @@ describe('#getters', () => {
inboxes: { 1: 2 },
labels: {},
teams: {},
folders: {},
};
expect(getters.getInboxUnreadCount(state)(1)).toBe(2);
@@ -20,6 +21,7 @@ describe('#getters', () => {
inboxes: {},
labels: { 3: 4 },
teams: {},
folders: {},
};
expect(getters.getLabelUnreadCount(state)(3)).toBe(4);
@@ -33,6 +35,7 @@ describe('#getters', () => {
inboxes: {},
labels: {},
teams: { 5: 6 },
folders: {},
};
expect(getters.getTeamUnreadCount(state)(5)).toBe(6);
@@ -46,21 +49,44 @@ describe('#getters', () => {
inboxes: {},
labels: {},
teams: {},
folders: {},
};
expect(getters.getAllUnreadCount(state)).toBe(7);
});
it('returns filtered unread counts', () => {
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 1,
participatingCount: 2,
unattendedCount: 3,
folders: { 8: 4 },
};
expect(getters.getMentionsUnreadCount(state)).toBe(1);
expect(getters.getParticipatingUnreadCount(state)).toBe(2);
expect(getters.getUnattendedUnreadCount(state)).toBe(3);
expect(getters.getFolderUnreadCount(state)(8)).toBe(4);
expect(getters.getFolderUnreadCount(state)('8')).toBe(4);
expect(getters.getFolderUnreadCount(state)(9)).toBe(0);
});
it('returns unread count maps', () => {
const state = {
allCount: 0,
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
folders: { 7: 8 },
};
expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
expect(getters.getFolderUnreadCounts(state)).toEqual({ 7: 8 });
});
});
@@ -4,7 +4,16 @@ import { mutations } from '../../conversationUnreadCounts';
describe('#mutations', () => {
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
it('normalizes unread count payload', () => {
const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: '3',
@@ -21,6 +30,13 @@ describe('#mutations', () => {
6: '7',
7: 0,
},
mentions_count: '8',
participating_count: 9,
unattended_count: 0,
folders: {
10: '11',
12: -1,
},
});
expect(state).toEqual({
@@ -28,6 +44,10 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 0,
folders: { 10: 11 },
});
});
@@ -37,6 +57,10 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 10,
folders: { 11: 12 },
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
@@ -46,17 +70,36 @@ describe('#mutations', () => {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
});
});
it('normalizes invalid aggregate counts to zero', () => {
const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} };
const state = {
allCount: 2,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 2,
participatingCount: 3,
unattendedCount: 4,
folders: {},
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: 'invalid',
mentions_count: 'invalid',
participating_count: -1,
unattended_count: 0,
});
expect(state.allCount).toBe(0);
expect(state.mentionsCount).toBe(0);
expect(state.participatingCount).toBe(0);
expect(state.unattendedCount).toBe(0);
});
});
});
@@ -1,11 +1,32 @@
import axios from 'axios';
import { actions } from '../../conversationWatchers';
import types from '../../../mutation-types';
import { FEATURE_FLAGS } from '../../../../featureFlags';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
const mockRetryJitter = value =>
vi.spyOn(Math, 'random').mockReturnValue(value);
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
const conversationUnreadCountsEnabledRootGetters = {
getCurrentAccountId: 1,
getCurrentUserID: 1,
'accounts/isFeatureEnabledonAccount': vi.fn((_, featureFlag) =>
[
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS,
FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS,
].includes(featureFlag)
),
};
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -48,6 +69,56 @@ describe('#actions', () => {
[types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, { isUpdating: false }],
]);
});
it('refetches unread counts when the current user starts watching', async () => {
vi.useFakeTimers();
mockRetryJitter(0.5);
const dispatch = vi.fn();
const moduleState = { records: { 2: [] } };
const mutatingCommit = vi.fn((mutation, payload) => {
if (mutation === types.SET_CONVERSATION_PARTICIPANTS) {
moduleState.records[payload.conversationId] = payload.data;
}
});
axios.patch.mockResolvedValue({ data: [{ id: 1 }] });
await actions.update(
{
commit: mutatingCommit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
state: moduleState,
},
{ conversationId: 2, userIds: [1] }
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
vi.advanceTimersByTime(37499);
expect(dispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(dispatch).toHaveBeenCalledTimes(2);
});
it('does not refetch unread counts when another watcher changes', async () => {
const dispatch = vi.fn();
axios.patch.mockResolvedValue({ data: [{ id: 1 }, { id: 2 }] });
await actions.update(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
state: { records: { 2: [{ id: 1 }] } },
},
{ conversationId: 2, userIds: [1, 2] }
);
expect(dispatch).not.toHaveBeenCalled();
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
@@ -357,11 +357,12 @@ describe('#actions', () => {
});
await actions.assignAgent(
{ dispatch },
{ conversationId: 1, agentId: 1 }
{ conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
});
});
});
@@ -371,6 +372,7 @@ describe('#actions', () => {
const payload = {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
};
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1);
@@ -712,8 +712,10 @@ describe('#mutations', () => {
mutations[types.ASSIGN_AGENT](state, {
conversationId: 1,
assignee,
assigneeType: 'AgentBot',
});
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot');
expect(state.allConversations[1].meta.assignee).toBeUndefined();
});
});
@@ -1,6 +1,7 @@
import axios from 'axios';
import * as types from '../../../mutation-types';
import { actions } from '../../customViews';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import {
contactFilterView,
customViewList,
@@ -11,6 +12,25 @@ const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
const mockRetryJitter = value =>
vi.spyOn(Math, 'random').mockReturnValue(value);
const conversationUnreadCountsEnabledRootGetters = {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn((_, featureFlag) =>
[
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS,
FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS,
].includes(featureFlag)
),
};
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -49,6 +69,36 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
it('refetches unread counts after creating a conversation folder', async () => {
vi.useFakeTimers();
mockRetryJitter(0.5);
const dispatch = vi.fn();
const firstItem = customViewList[0];
axios.post.mockResolvedValue({ data: firstItem });
await actions.create(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
firstItem
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
vi.advanceTimersByTime(37499);
expect(dispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(dispatch).toHaveBeenCalledTimes(2);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit })).rejects.toThrow(Error);
@@ -69,6 +119,44 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false }],
]);
});
it('refetches unread counts after deleting a conversation folder', async () => {
vi.useFakeTimers();
const dispatch = vi.fn();
axios.delete.mockResolvedValue({ data: customViewList[0] });
await actions.delete(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
{ id: 1, filterType: 'conversation' }
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
});
it('does not refetch unread counts after deleting a contact segment', async () => {
const dispatch = vi.fn();
axios.delete.mockResolvedValue({ data: contactFilterView });
await actions.delete(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
{ id: 1, filterType: 'contact' }
);
expect(dispatch).not.toHaveBeenCalled();
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.delete({ commit }, 1)).rejects.toThrow(Error);
@@ -93,6 +181,29 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
it('refetches unread counts after updating a conversation folder', async () => {
vi.useFakeTimers();
const dispatch = vi.fn();
const item = updateCustomViewList[0];
axios.patch.mockResolvedValue({ data: item });
await actions.update(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
item
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.update({ commit }, 1)).rejects.toThrow(Error);
@@ -7,12 +7,21 @@ global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('#fetch', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetch({ commit }, [1]);
expect(axios.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
},
});
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }],
[
@@ -24,13 +33,37 @@ describe('#actions', () => {
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.fetch({ commit }, { inboxId: 1 })).rejects.toThrow(
Error
);
await expect(actions.fetch({ commit }, [1])).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }],
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false }],
]);
});
it('requests agent bots only when opted in', async () => {
axios.get.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetch(
{ commit },
{ inboxIds: [1], includeAgentBots: true }
);
expect(axios.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
include_agent_bots: true,
},
});
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: '1',
members: agentsData,
});
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: '1:with_agent_bots',
members: agentsData,
});
});
});
});
@@ -1,4 +1,4 @@
import { getters } from '../../teamMembers';
import { getters } from '../../inboxAssignableAgents';
import agentsData from './fixtures';
describe('#getters', () => {
@@ -8,7 +8,26 @@ describe('#getters', () => {
1: [agentsData[0]],
},
};
expect(getters.getTeamMembers(state)(1)).toEqual([agentsData[0]]);
expect(getters.getAssignableAgents(state)(1)).toEqual([agentsData[0]]);
});
it('keeps agent bots scoped to bot-inclusive lists', () => {
const agentBot = {
id: 1,
name: 'Captain',
assignee_type: 'AgentBot',
};
const state = {
records: {
1: [agentBot, agentsData[0]],
'1:with_agent_bots': [agentBot, agentsData[0]],
},
};
expect(getters.getAssignableAgents(state)(1)).toEqual([agentsData[0]]);
expect(
getters.getAssignableAgents(state)(1, { includeAgentBots: true })
).toEqual([agentBot, agentsData[0]]);
});
it('getUIFlags', () => {
@@ -83,7 +83,7 @@ describe('#actions', () => {
);
});
it('ignores invalid preferences', () => {
it('ignores invalid sort values', () => {
actions.setSectionSort(
{
commit,
@@ -95,7 +95,7 @@ describe('#actions', () => {
},
{
section: SIDEBAR_SORT_SECTIONS.FOLDERS,
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
sortBy: 'invalid_sort',
}
);
@@ -63,6 +63,14 @@ const hasValue = computed(() => {
const hasIcon = computed(() => {
return props.selectedItem?.icon || false;
});
const isAgentBot = computed(
() => props.selectedItem?.assignee_type === 'AgentBot'
);
const selectedThumbnail = computed(
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
);
</script>
<template>
@@ -93,16 +101,25 @@ const hasIcon = computed(() => {
</h4>
</div>
<Avatar
v-if="hasValue && hasThumbnail && !hasIcon"
:src="selectedItem.thumbnail"
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
:src="selectedThumbnail"
:status="selectedItem.availability_status"
:name="selectedItem.name"
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
:size="24"
hide-offline-status
rounded-full
/>
>
<template v-if="isAgentBot && selectedThumbnail" #badge>
<div
class="absolute z-20 flex items-center justify-center rounded-full outline outline-1 outline-n-weak bg-n-solid-1 -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3.5"
>
<Icon icon="i-lucide-bot" class="text-n-slate-11 size-2.5" />
</div>
</template>
</Avatar>
<div
v-if="hasValue && hasIcon && showEmojiIcon"
v-else-if="hasValue && hasIcon && showEmojiIcon"
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
>
<EmojiIcon

Some files were not shown because too many files have changed in this diff Show More