Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub a2b82111a7 Merge branch 'develop' into fix/idb-cache-cleanup 2026-02-17 14:09:00 +05:30
Sivin VargheseandGitHub c5f6844877 fix: Disable reply editor outside WhatsApp reply window (#13454) 2026-02-17 14:07:36 +05:30
Sivin VargheseandGitHub 15547fe2cd Merge branch 'develop' into fix/idb-cache-cleanup 2026-02-17 14:04:17 +05:30
Shivam MishraandGitHub 39243b9e71 fix: duplicate message_created webhooks for WhatsApp messages (#13523)
Some customers using WhatsApp inboxes with account-level webhooks were
reporting receiving duplicate `message_created` webhook deliveries for
every incoming message. Upon inspection, here's what we found

- Both payloads are identical.
- No errors appear in the application logs
- Webhook URL is only configured in one place. 

This meant, the system was sending the webhooks twice. For some context,
there's a know related issue... Meta's WhatsApp Business API can deliver
the same webhook notification multiple times for a single message. The
codebase already acknowledges this — there's a comment in
`IncomingMessageBaseService#process_messages` noting that "multiple
webhook events can be received against the same message due to
misconfigurations in the Meta business manager account." A deduplication
guard exists, but it doesn't actually work under concurrency.

### Rationale

The existing dedup was a three-step sequence: check Redis (`GET`), check
the database, then set a Redis flag (`SETEX`). Two Sidekiq workers
processing duplicate Meta webhooks simultaneously would both complete
the `GET` before either executed the `SETEX`, so both would proceed to
create a message. The `source_id` column has a non-unique index, so the
database wouldn't catch the duplicate either. Each message then
independently fires `after_create_commit`, dispatching two
`message_created` webhook events to the customer.

```
             Worker A                          Worker B
                │                                 │
                ▼                                 ▼
        Redis GET key ──► nil               Redis GET key ──► nil
                │                                 │
                │    ◄── both pass guard ──►      │
                │                                 │
                ▼                                 ▼
        Redis SETEX key                    Redis SETEX key
                │                                 │
                ▼                                 ▼
        BEGIN transaction               BEGIN transaction
        INSERT message                   INSERT message
        DELETE Redis key ◄─┐                      │
        COMMIT             │             DELETE Redis key
                           │             COMMIT
                           │                      │
                           └── key gone before ───┘
                              B's commit lands

                ▼                                 ▼
        after_create_commit              after_create_commit
        dispatch MESSAGE_CREATED         dispatch MESSAGE_CREATED
                │                                 │
                ▼                                 ▼
        WebhookJob ──► n8n               WebhookJob ──► n8n
                    (duplicate!)
```

There was a second, subtler problem visible in the diagram: the Redis
key was cleared *inside* the database transaction, before the
transaction committed. This opened a window where neither the Redis
check nor the database check would see the in-flight message.

The fix collapses the check-and-set into a single `SET NX EX` call,
which is atomic in Redis. The key is no longer eagerly cleared — it
expires naturally after 24 hours. The database lookup
(`find_message_by_source_id`) remains as a fallback for messages that
were created before the lock expired.

```
             Worker A                          Worker B
                │                                 │
                ▼                                 ▼
        Redis SET NX ──► OK              Redis SET NX ──► nil
                │                                 │
                ▼                                 ▼
        proceeds to create              returns early
        message normally                (lock already held)
```

### Implementation Notes

The lock logic is extracted into `Whatsapp::MessageDedupLock`, a small
class that wraps a single `Redis SET NX EX` call. This makes the
concurrency guarantee testable in isolation — the spec uses a
`CyclicBarrier` to race two threads against the same key and asserts
exactly one wins, without needing database writes,
`use_transactional_tests = false`, or monkey-patching.

Because the Redis lock now persists (instead of being cleared
mid-transaction), existing WhatsApp specs needed an `after` hook to
clean up `MESSAGE_SOURCE_KEY::*` keys between examples. Transactional
fixtures only roll back the database, not Redis.
2026-02-17 14:01:10 +05:30
Sivin VargheseandGitHub fb2f5e1d42 fix: Persist compose form state on accidental outside click (#13529) 2026-02-17 13:57:44 +05:30
Shivam MishraandGitHub 756aea3554 Merge branch 'develop' into fix/idb-cache-cleanup 2026-02-17 13:37:54 +05:30
Shivam Mishra 316d116cec fix: await IndexedDB cache clear on inbox deletion 2026-02-17 13:37:30 +05:30
Sivin VargheseandGitHub cfe3061b5d feat: Allow removing labels via conversation context menu (#13525)
# Pull Request Template

## Description

This PR adds support for removing labels from the conversation card
context menu. Assigned labels now show a checkmark, and clicking an
already-selected label will remove it.

Fixes
https://linear.app/chatwoot/issue/CW-6400/allow-removing-labels-directly-from-the-right-click-menu
https://github.com/chatwoot/chatwoot/issues/13367
## Type of change

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

## How Has This Been Tested?

**Screencast**


https://github.com/user-attachments/assets/4e3a6080-a67d-4851-9d10-d8dbf3ceeb04




## 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
- [ ] 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-02-17 13:30:55 +05:30
Aakash BakhleandGitHub aa7e3c2d38 feat: langfuse logging improvements (#13534)
Langfuse logging improvements

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes # (issue)
For reply suggestion: the errors are being stored inside output field,
but observations should be marked as errors.
For assistant: add credit_used metadata to filter handoffs from
ai-replies
For langfuse tool call: add `observation_type=tool`

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
## 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.

before:
<img width="1028" height="57" alt="image"
src="https://github.com/user-attachments/assets/70f6a36e-6c33-444c-a083-723c7c9e823a"
/>

after:
<img width="872" height="69" alt="image"
src="https://github.com/user-attachments/assets/1b6b6f5f-5384-4e9c-92ba-f56748fec6dd"
/>

`credit_used` to filter handoffs from AI replies that cause credit usage
<img width="1082" height="672" alt="image"
src="https://github.com/user-attachments/assets/90914227-553a-4c03-bc43-56b2018ac7c1"
/>


set `observation_type` to `tool`
<img width="726" height="1452" alt="image"
src="https://github.com/user-attachments/assets/e639cc9b-1c6c-4427-887e-23e5523bf64f"
/>


## 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
- [ ] 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-02-17 13:30:04 +05:30
3874383698 feat: insrument captain v2 (#13439)
# Pull Request Template

## Description

Instruments 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.

Local testing:
<img width="864" height="510" alt="image"
src="https://github.com/user-attachments/assets/855ebce5-e8b8-4d22-b0bb-0d413769a6ab"
/>



## 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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-02-17 13:28:26 +05:30
Aakash BakhleandGitHub 101eca3003 feat: add captain editor events (#13524)
## Description

Adds missing analytics instrumentation for the editor AI funnel so we
can measure end-to-end usage and outcome quality.

### What was added

- Captain: Editor AI menu opened
- Captain: Generation failed
- Captain: AI-assisted message sent

### Behavior covered

- Tracks AI button click + menu open from both entry points:
    - top panel sparkle button
    - inline editor copilot button
- Tracks generation failures (initial + follow-up stages).
- Tracks whether accepted AI content was sent as-is or edited before
send.

### Notes

- Applies to editor Captain accept/send flow
(rewrite/summarize/reply_suggestion + follow-ups).
- Does not change Copilot sidebar flow instrumentation.

## Type of change

- [x] 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?

### Manual verification steps

<img width="1906" height="832" alt="image"
src="https://github.com/user-attachments/assets/f0ade43b-aa8d-41be-8ca2-20a091a81f60"
/>

<img width="828" height="280" alt="image"
src="https://github.com/user-attachments/assets/be76219e-fb61-4a6e-bff5-dc085b0a3cc9"
/>

<img width="415" height="147" alt="image"
src="https://github.com/user-attachments/assets/36802c5c-33a7-49ed-bf7e-f0b02d86dccc"
/>

<img width="2040" height="516" alt="image"
src="https://github.com/user-attachments/assets/74b95288-bc86-4312-a282-14211ae8f25c"
/>


1. Open a conversation with Captain tasks enabled.
2. Click AI button in top panel and inline editor.
3. Confirm analytics events fire for:
    - AI menu opened
4. Run an AI action and force a failure scenario (or empty response
path) and confirm generation-failed event.
5. Accept AI output, then:
    - send without changes -> editedBeforeSend: false
    - edit then send -> editedBeforeSend: true

## 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
- [ ] 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-02-17 13:26:56 +05:30
Sojan JoseandGitHub 61eaa098ae fix(messages): reduce audio transcription 400 retry noise (#13487)
## Summary
This PR reduces duplicate failure noise for audio transcription jobs
that fail with permanent HTTP 400 responses, and fixes a file-format
edge case causing intermittent 400s.

Sentry issue: [CHATWOOT-99E /
6660541334](https://chatwoot-p3.sentry.io/issues/6660541334/)

## Confirmed root cause
For some attachments, the stored filename had no extension (example:
`speech`, content type `audio/mpeg`).
When the temporary transcription upload file was created without an
extension, OpenAI returned:
`Unrecognized file format` (HTTP 400).

## Scope of changes
1. `Messages::AudioTranscriptionJob`
- Keeps `discard_on Faraday::BadRequestError` to avoid retry storms on
permanent request errors.
- Adds explicit Rails warning logs for discarded jobs with
attachment/job/status context.

2. `Messages::AudioTranscriptionService`
- Keeps guaranteed temp file cleanup via `ensure`.
- Ensures temp upload files include an extension when the original
filename has none, derived from blob `content_type`.
- This addresses intermittent failures like extensionless `audio/mpeg`
files.

## Reproduction
Enable audio transcription for an account and process an audio
attachment whose stored filename has no extension (for example `speech`)
but valid audio content type (`audio/mpeg`).
Before this fix, OpenAI transcription could return HTTP 400
`Unrecognized file format` for that attachment while similar attachments
with extensions succeeded.

## Testing
Ran:
`bundle exec rubocop
enterprise/app/jobs/messages/audio_transcription_job.rb
enterprise/app/services/messages/audio_transcription_service.rb`

Result: both modified files pass lint with no offenses.
2026-02-17 13:25:13 +05:30
9cd7c4ef89 fix: Enhance notification emails with message details and handle failed messages (#13273)
## Description

Handle messages with null content properly in UI and email notifications

## Type of change

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

## Relevant Screenshots:
<img width="688" height="765" alt="Screenshot 2026-01-21 at 4 43 00 PM"
src="https://github.com/user-attachments/assets/6a27c22e-2ae6-4377-a05d-cfa44bf181fe"
/>


## 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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches notification email templates and message rendering conditions;
mistakes could lead to missing content/attachments in emails or
incorrect UI visibility, but changes are localized and non-auth/security
related.
> 
> **Overview**
> Agent notification emails for *assigned* and *participating* new
messages now include the actual message details (sender name, rendered
text when present, and attachment links) and gracefully fall back when
content is unavailable.
> 
> To support this, the mailer now passes `@message` into Liquid via
`MessageDrop` (adding `attachments` URLs), and the dashboard message UI
now renders failed/external-error messages even when `content` is `null`
while tightening retry eligibility to require content or attachments
(and still within 1 day).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
475c8cedda54eb5e806990f977faf8098d0b27d8. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-16 14:47:33 +05:30
Tanmay Deep SharmaandGitHub f4538ae2c5 fix: Enforce team boundaries to prevent cross-team assignments (#13353)
## Description

Fixes a critical bug where conversations assigned to a team could be
auto-assigned to agents outside that team when all team members were at
capacity.

## Type of change

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

## 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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes core assignment selection for both legacy and v2 flows;
misconfiguration of `allow_auto_assign` or team membership could cause
conversations to remain unassigned.
> 
> **Overview**
> Prevents auto-assignment from crossing team boundaries by filtering
eligible agents to the conversation’s `team` members (and requiring
`team.allow_auto_assign`) in both the legacy `AutoAssignmentHandler`
path and the v2 `AutoAssignment::AssignmentService` (including the
Enterprise override).
> 
> Adds test coverage to ensure team-scoped conversations only assign to
team members, and are skipped when team auto-assign is disabled or no
team members are available; also updates the conversations controller
spec setup to include team membership.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
67ed2bda0cd8ffd56c7e0253b86369dead2e6155. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-16 14:39:20 +05:30
Sojan JoseandGitHub fd5ac2a8a3 fix: apply installation branding replacement in tooltip copy (#13538)
## Summary
Fix hardcoded `Chatwoot` branding in two UI tooltips using the existing
`useBranding` flow so self-hosted/white-label deployments no longer show
the wrong brand text.

## Changes
- LabelSuggestion tooltip now uses:
  - `replaceInstallationName($t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'))`
- Message avatar tooltip (native app/external echo) now uses:
  - `replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'))`

## Why
This follows the existing branding pattern already used in the product
and keeps behavior consistent across deployments.

## Notes
- No change to message logic or API behavior.
- `AGENTS.md` updated with a branding guidance note.

## Fixes
- Fixes https://github.com/chatwoot/chatwoot/issues/13306
- Fixes https://github.com/chatwoot/chatwoot/issues/13466

## Testing

<img width="195" height="155" alt="Screenshot 2026-02-13 at 3 55 39 PM"
src="https://github.com/user-attachments/assets/5b295cdd-6e5d-42c0-bbd7-23ba7052e1c3"
/>
<img width="721" height="152" alt="Screenshot 2026-02-13 at 3 55 48 PM"
src="https://github.com/user-attachments/assets/19cec2a0-451f-4fb3-bd61-7c2e591fc3c7"
/>
2026-02-13 16:47:25 -08:00
Sojan JoseandGitHub 6b7180d051 fix(twilio): prevent dead jobs on missing channel lookup (#13522)
## Why
We observed `Webhooks::TwilioEventsJob` failures ending up in Sidekiq
dead jobs when Twilio callback payloads could not be mapped to a
`Channel::TwilioSms` record. In this scenario, channel lookup raised
`ActiveRecord::RecordNotFound`, which caused retries and eventual dead
jobs instead of a graceful drop.

Related Sentry issue/search:
-
https://chatwoot-p3.sentry.io/issues/?project=6382945&query=Webhooks%3A%3ATwilioEventsJob%20ActiveRecord%3A%3ARecordNotFound

## What changed
This PR keeps the existing lookup flow but makes it non-raising:
- `app/services/twilio/incoming_message_service.rb`
  - `find_by!` -> `find_by` for account SID + phone lookup
  - Added warning log when channel lookup misses
- `app/services/twilio/delivery_status_service.rb`
  - `find_by!` -> `find_by` for account SID + phone lookup
  - Added warning log when channel lookup misses

## Reproduction
Configure a Twilio webhook callback that reaches Chatwoot but does not
match an existing Twilio channel lookup path. Before this change, the
job raises `RecordNotFound` and can end up in dead jobs after retries.
After this change, the job logs the miss and exits safely.

## Testing
- `bundle exec rspec
spec/services/twilio/incoming_message_service_spec.rb
spec/services/twilio/delivery_status_service_spec.rb`
- `bundle exec rubocop app/services/twilio/incoming_message_service.rb
app/services/twilio/delivery_status_service.rb`
2026-02-13 14:06:12 -08:00
Shivam Mishra ff5440173c fix: clear IndexedDB cache after deleting inbox 2026-02-13 16:12:31 +05:30
Shivam Mishra abb5a4c4d8 feat: add clearDataInCache method to CacheEnabledApiClient 2026-02-13 16:12:29 +05:30
Shivam Mishra 37da4dd8ca fix: invalidate inbox cache key immediately on delete 2026-02-13 16:12:26 +05:30
João Pedro Baza Garcia RodriguesandGitHub 4d362da9f0 fix: Prevent user enumeration on password reset endpoint (#13528)
## Description

The current password reset endpoint returns different HTTP status codes
and messages depending on whether the email exists in the system (200
for existing emails, 404 for non-existing ones). This allows attackers
to enumerate valid email addresses via the password reset form.

## Changes

### `app/controllers/devise_overrides/passwords_controller.rb`
- Removed the `if/else` branch that returned different responses based
on email existence
- Now always returns a generic `200 OK` response with the same message
regardless of whether the email exists
- Uses safe navigation operator (`&.`) to send reset instructions only
if the user exists

### `config/locales/en.yml`
- Consolidated `reset_password_success` and `reset_password_failure`
into a single generic `reset_password` key
- New message does not reveal whether the email exists in the system

## Security Impact
- **Before**: An attacker could determine if an email was registered by
observing the HTTP status code (200 vs 404) and response message
- **After**: All requests receive the same 200 response with a generic
message, preventing user enumeration

This follows [OWASP guidelines for authentication error
messages](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#authentication-responses).

Fixes #13527
2026-02-13 13:45:40 +05:30
2c2f0547f7 fix: Captain not responding to campaign conversations (#13489)
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-02-12 10:07:56 +05:30
Sojan JoseandGitHub c7193c7917 fix(slack): handle archived channel errors in SendOnSlackJob (#13520)
When a Slack-integrated channel is archived, posting from Chatwoot
raises `Slack::Web::Api::Errors::IsArchived` in `SendOnSlackJob`, which
retries and can end up in dead jobs. This can be reproduced by archiving
the connected Slack channel for a valid hook and creating outgoing
messages. This change adds `IsArchived` to the existing handled Slack
API rescue path in
`Integrations::Slack::SendOnSlackService#send_message`, so
archived-channel failures now follow the same flow as related Slack
failures (`prompt_reauthorization!` + `disable`) instead of bubbling and
retrying repeatedly. I tested this by running `bundle exec rubocop
lib/integrations/slack/send_on_slack_service.rb` (with `rbenv`
initialized), and it passes with no offenses.

Sentry issue: https://chatwoot-p3.sentry.io/issues/7150427066/
2026-02-11 17:05:44 -08:00
Sojan JoseandGitHub d272a64ff7 fix(mailbox): handle malformed sender address headers (#13486)
## How to reproduce
When an inbound email has malformed sender headers (for example `From:
McDonald <info@example.com` without a closing `>`), mailbox
processing can raise `Mail::Field::IncompleteParseError` while resolving
sender data in `MailPresenter`.

## What changed
This PR hardens sender parsing in `MailPresenter` with a small, readable
implementation:
- Added/used a safe parser (`parse_mail_address`) that rescues
`Mail::Field::ParseError` and `Mail::Field::IncompleteParseError`.
- `sender_name` now uses the same safe parser path.
- `original_sender` now resolves candidates in order via a compact
`filter_map` flow:
  - `Reply-To`
  - `X-Original-Sender`
  - `From`
- All three candidates are parsed as email addresses before use
(including `X-Original-Sender`), and invalid values are ignored.
- `notification_email_from_chatwoot?` now compares sender addresses
case-insensitively (`casecmp?`) to avoid case-only mismatches.

## Test coverage
Added focused presenter specs for:
- malformed `From` header returns nil sender values and does not
classify as notification sender
- malformed `Reply-To` falls back to valid `From`
- valid `X-Original-Sender` is used when present
- invalid `X-Original-Sender` falls back to valid `From`
- mixed-case sender address still matches configured
`MAILER_SENDER_EMAIL`

## How this was tested
Ran:
- `bundle exec rspec spec/presenters/mail_presenter_spec.rb`
- `bundle exec rubocop app/presenters/mail_presenter.rb
spec/presenters/mail_presenter_spec.rb`

Sentry issue:
[CHATWOOT-B9Y](https://chatwoot-p3.sentry.io/issues/7005483640/)
2026-02-11 11:02:38 -08:00
Sojan JoseandGitHub b2cb3717e5 fix: Replace default Rails error pages with custom designs (#13514)
## Summary

- Replace generic Rails 404, 422, and 500 error pages with
custom-designed pages
- Each page features a prominent gradient error code, clean typography,
and a "Back to home" CTA
- Full dark mode support via `prefers-color-scheme` media query  
- Mobile responsive design  
- No external dependencies (self-contained static HTML, works even when
Rails is down)
- No logo/branding to ensure compatibility with custom-branded
self-hosted instances

---

## Before (default Rails 404)

<img width="2400" height="1998" alt="old-404"
src="https://github.com/user-attachments/assets/c5f044c4-aab6-40e9-aa11-6096ed9d2b42"
/>

---

## After

### Light mode

| 404 | 422 | 500 |
|-----|-----|-----|
| <img width="600" alt="new-404-light"
src="https://github.com/user-attachments/assets/1826c812-0eb9-4219-bdd2-026c54b53123"
/> | <img width="600" alt="new-422-light"
src="https://github.com/user-attachments/assets/d72ffdbf-b61e-4f53-a16b-4ee81103124f"
/> | <img width="600" alt="new-500-light"
src="https://github.com/user-attachments/assets/81bbdb24-bfe7-43a1-b584-f37f71e3bded"
/> |

---

### Dark mode

| 404 | 422 | 500 |
|-----|-----|-----|
| <img width="600" alt="new-404-dark"
src="https://github.com/user-attachments/assets/bd323915-bfb9-48c1-885d-96ff263b4ae0"
/> | <img width="600" alt="new-422-dark"
src="https://github.com/user-attachments/assets/dcb08eca-aee5-4e36-9690-f44da13e8d88"
/> | <img width="600" alt="new-500-dark"
src="https://github.com/user-attachments/assets/538c3c2c-b9dc-406c-9932-ff8897b64790"
/> |

---

## Test plan

Easier way to verify:

- [ ] Visit `/404.html` directly to verify the 404 page  
- [ ] Visit `/422.html` directly to verify the 422 page  
- [ ] Visit `/500.html` directly to verify the 500 page  

Full verification:

- [ ] Visit a non-existent route in production mode to verify the 404
page
- [ ] Trigger a 422 error (e.g., invalid CSRF token) to verify the 422
page
- [ ] Trigger a 500 error to verify the 500 page  
- [ ] Toggle system dark mode and verify all pages render correctly  
- [ ] Test on mobile viewport widths
2026-02-11 07:57:00 -08:00
Vishnu NarayananandGitHub 00ed074d72 fix: disable email transcript for free plans (#13509)
- Block email transcript functionality for accounts without a paid plan
to prevent SES abuse.
2026-02-11 21:21:36 +05:30
7b512bd00e fix: V2 Assignment service enhancements (#13036)
## Linear Ticket:
https://linear.app/chatwoot/issue/CW-6081/review-feedback

## Description

Assignment V2 Service Enhancements

- Enable Assignment V2 on plan upgrade
- Fix UI issue with fair distribution policy display
- Add advanced assignment feature flag and enhance Assignment V2
capabilities

## Type of change

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

## How Has This Been Tested?

This has been tested using the UI.

## 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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes auto-assignment execution paths, rate limiting defaults, and
feature-flag gating (including premium plan behavior), which could
affect which conversations get assigned and when. UI rewires inbox
settings and policy flows, so regressions are possible around
navigation/linking and feature visibility.
> 
> **Overview**
> **Adds a new premium `advanced_assignment` feature flag** and uses it
to gate capacity/balanced assignment features in the UI (sidebar entry,
settings routes, assignment-policy landing cards) and backend
(Enterprise balanced selector + capacity filtering).
`advanced_assignment` is marked premium, included in Business plan
entitlements, and auto-synced in Enterprise accounts when
`assignment_v2` is toggled.
> 
> **Improves Assignment V2 policy UX** by adding an inbox-level
“Conversation Assignment” section (behind `assignment_v2`) that can
link/unlink an assignment policy, navigate to create/edit policy flows
with `inboxId` query context, and show an inbox-link prompt after
creating a policy. The policy form now defaults to enabled, disables the
`balanced` option with a premium badge/message when unavailable, and
inbox lists support click-to-navigate.
> 
> **Tightens/adjusts auto-assignment behavior**: bulk assignment now
requires `inbox.enable_auto_assignment?`, conversation ordering uses the
attached `assignment_policy` priority, and rate limiting uses
`assignment_policy` config with an infinite default limit while still
tracking assignments. Tests and i18n strings are updated accordingly.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
23bc03bf75ee4376071e4d7fc7cd564c601d33d7. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-02-11 12:24:45 +05:30
PranavandGitHub 8f95fafff4 feat: Add a setting to keep conversations pending on bot failures (#13512)
Adds an account-level setting `keep_pending_on_bot_failure` to control
whether conversations should move from pending to open when agent bot
webhooks fail.

Some users experience occasional message drops and don't want
conversations to automatically reopen due to transient bot failures.
This setting gives accounts control over that behavior. This is a
temporary setting which will be removed in future once a proper fix for
it is done, so it is not added in the UI.
2026-02-10 17:27:42 -08:00
0ad47d87f4 fix: Use Faraday for Telegram document uploads to fix large file failures (#13397)
Fixes
https://linear.app/chatwoot/issue/CW-6415/sending-large-attachments-11mb-via-telegram-channels-fails-with-http

 #### Issue
Sending large attachments (~11MB) via Telegram channels fails with HTTP
502 (Bad Gateway) and 413 (Request Entity Too Large) errors. The issue
is caused by HTTParty's built-in multipart encoding, which reads the
entire file into an in-memory string before constructing the request
body. For large files, this produces a malformed multipart request that
Telegram's API proxy rejects.

#### Solution

Replace HTTParty with Faraday + multipart-post (both already available
in the project) for the sendDocument multipart upload. The
multipart-post gem streams file content directly from disk into the HTTP
request, producing a correctly formed multipart body that Telegram
accepts for large files.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-02-10 14:25:25 -08:00
Sivin VargheseandGitHub e65ea24360 fix: Wrong assignee displayed after switching conversations (#13501) 2026-02-10 15:23:55 +05:30
b252656984 fix: Prevent race condition in conversation dataFetched flag (#13492)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-02-10 15:23:14 +05:30
Aakash BakhleandGitHub 6e397c7571 fix: default model for captain assistant (#13496) 2026-02-10 14:53:53 +05:30
Sojan JoseandGitHub 4622560fac chore(dev): document codex worktree local setup (#13494)
This PR standardizes local Codex worktree usage with a simple
dynamic-port workflow and ensures local-only artifacts stay out of
version control.

To reproduce: create a Codex worktree, run the setup script from
`.codex/environments/environment.toml`, and verify that it generates
per-worktree DB and port values along with a `Procfile.worktree` for
Overmind.

Changes included:
- Add `.codex/` and `Procfile.worktree` to `.gitignore`
- Document the Codex Worktree Workflow in `AGENTS.md`, outlining
expected local setup conventions

Tested locally by running the setup script with `CODEX_SKIP_INSTALL=1`
and `CODEX_SKIP_DB_PREPARE=1`. Verified successful output, dynamic
`FRONTEND_URL` / Vite port generation, and that `git diff` contains only
the intended documentation and ignore updates.
2026-02-09 20:56:40 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6632610e78 chore(deps): bump faraday from 2.13.1 to 2.14.1 (#13503)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.13.1 to
2.14.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/lostisland/faraday/releases">faraday's
releases</a>.</em></p>
<blockquote>
<h2>v2.14.1</h2>
<h2>Security Note</h2>
<p>This release contains a security fix, we recommend all users to
upgrade as soon as possible.
A Security Advisory with more details will be posted shortly.</p>
<h2>What's Changed</h2>
<ul>
<li>Add comprehensive AI agent guidelines for Claude, Cursor, and GitHub
Copilot by <a
href="https://github.com/Copilot"><code>@​Copilot</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1642">lostisland/faraday#1642</a></li>
<li>Add RFC document for Options architecture refactoring plan by <a
href="https://github.com/Copilot"><code>@​Copilot</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1644">lostisland/faraday#1644</a></li>
<li>Bump actions/checkout from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/lostisland/faraday/pull/1655">lostisland/faraday#1655</a></li>
<li>Explicit top-level namespace reference by <a
href="https://github.com/c960657"><code>@​c960657</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1657">lostisland/faraday#1657</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Copilot"><code>@​Copilot</code></a> made
their first contribution in <a
href="https://redirect.github.com/lostisland/faraday/pull/1642">lostisland/faraday#1642</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lostisland/faraday/compare/v2.14.0...v2.14.1">https://github.com/lostisland/faraday/compare/v2.14.0...v2.14.1</a></p>
<h2>v2.14.0</h2>
<h2>What's Changed</h2>
<h3>New features </h3>
<ul>
<li>Use newer <code>UnprocessableContent</code> naming for 422 by <a
href="https://github.com/tylerhunt"><code>@​tylerhunt</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1638">lostisland/faraday#1638</a></li>
</ul>
<h3>Fixes 🐞</h3>
<ul>
<li>Convert strings to UTF-8 by <a
href="https://github.com/c960657"><code>@​c960657</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1624">lostisland/faraday#1624</a></li>
<li>Fix <code>Response#to_hash</code> when response not finished yet by
<a href="https://github.com/yykamei"><code>@​yykamei</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1639">lostisland/faraday#1639</a></li>
</ul>
<h3>Misc/Docs 📄</h3>
<ul>
<li>Lint: use <code>filter_map</code> by <a
href="https://github.com/olleolleolle"><code>@​olleolleolle</code></a>
in <a
href="https://redirect.github.com/lostisland/faraday/pull/1637">lostisland/faraday#1637</a></li>
<li>Bump <code>actions/checkout</code> from v4 to v5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/lostisland/faraday/pull/1636">lostisland/faraday#1636</a></li>
<li>Fixes documentation by <a
href="https://github.com/dharamgollapudi"><code>@​dharamgollapudi</code></a>
in <a
href="https://redirect.github.com/lostisland/faraday/pull/1635">lostisland/faraday#1635</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/c960657"><code>@​c960657</code></a> made
their first contribution in <a
href="https://redirect.github.com/lostisland/faraday/pull/1624">lostisland/faraday#1624</a></li>
<li><a
href="https://github.com/dharamgollapudi"><code>@​dharamgollapudi</code></a>
made their first contribution in <a
href="https://redirect.github.com/lostisland/faraday/pull/1635">lostisland/faraday#1635</a></li>
<li><a href="https://github.com/tylerhunt"><code>@​tylerhunt</code></a>
made their first contribution in <a
href="https://redirect.github.com/lostisland/faraday/pull/1638">lostisland/faraday#1638</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lostisland/faraday/compare/v2.13.4...v2.14.0">https://github.com/lostisland/faraday/compare/v2.13.4...v2.14.0</a></p>
<h2>v2.13.4</h2>
<h2>What's Changed</h2>
<ul>
<li>Improve error handling logic and add missing test coverage by <a
href="https://github.com/iMacTia"><code>@​iMacTia</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1633">lostisland/faraday#1633</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lostisland/faraday/compare/v2.13.3...v2.13.4">https://github.com/lostisland/faraday/compare/v2.13.3...v2.13.4</a></p>
<h2>v2.13.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix type assumption in <code>Faraday::Error</code> by <a
href="https://github.com/iMacTia"><code>@​iMacTia</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1630">lostisland/faraday#1630</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/lostisland/faraday/commit/16cbd38ef252d25dedf416a4d2510a2f3db10c87"><code>16cbd38</code></a>
Version bump to 2.14.1</li>
<li><a
href="https://github.com/lostisland/faraday/commit/a6d3a3a0bf59c2ab307d0abd91bc126aef5561bc"><code>a6d3a3a</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/lostisland/faraday/commit/b23f710d28c0dba169470f568df4017a1e8beea7"><code>b23f710</code></a>
Explicit top-level namespace reference (<a
href="https://redirect.github.com/lostisland/faraday/issues/1657">#1657</a>)</li>
<li><a
href="https://github.com/lostisland/faraday/commit/49ba4ac3a7359baed634c12a82386f6c8c717ea8"><code>49ba4ac</code></a>
Bump actions/checkout from 5 to 6 (<a
href="https://redirect.github.com/lostisland/faraday/issues/1655">#1655</a>)</li>
<li><a
href="https://github.com/lostisland/faraday/commit/51a49bc99d7df6f724d250d64771e1d710576df7"><code>51a49bc</code></a>
Ensure Claude reads the guidelines and allow to plan in a gitignored
.ai/PLAN...</li>
<li><a
href="https://github.com/lostisland/faraday/commit/894f65cab8f04bcf35e84a2dfd9fc0286dbce340"><code>894f65c</code></a>
Add RFC document for Options architecture refactoring plan (<a
href="https://redirect.github.com/lostisland/faraday/issues/1644">#1644</a>)</li>
<li><a
href="https://github.com/lostisland/faraday/commit/397e3ded0c5166313bb22f1c0221b36b6023fd0f"><code>397e3de</code></a>
Add comprehensive AI agent guidelines for Claude, Cursor, and GitHub
Copilot ...</li>
<li><a
href="https://github.com/lostisland/faraday/commit/d98c65cfc254ea2898386e4359428527122abec3"><code>d98c65c</code></a>
Update Faraday-specific AI agent guidelines</li>
<li><a
href="https://github.com/lostisland/faraday/commit/56c18ecb718e30c5a3a0dea9bd2361912af9013c"><code>56c18ec</code></a>
Add AI agent guidelines specific to Faraday repository</li>
<li><a
href="https://github.com/lostisland/faraday/commit/3201a42957d37efc968ee8834ba9b50ed5dde54a"><code>3201a42</code></a>
Version bump to 2.14.0</li>
<li>Additional commits viewable in <a
href="https://github.com/lostisland/faraday/compare/v2.13.1...v2.14.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=faraday&package-manager=bundler&previous-version=2.13.1&new-version=2.14.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 16:12:52 -08:00
Aakash BakhleandGitHub bd732f1fa9 fix: search faqs in account language (#13428)
# Pull Request Template

## Description

Reply suggestions uses `search_documentation`. While this is useful,
there is a subtle bug, a user's message may be in a different language
(say spanish) than the FAQs present (english).
This results in embedding search in spanish and compared against english
vectors, which results in poor retrieval and poor suggestions.


Fixes # (issue)
This PR fixes the above behaviour by making a small llm call translate
the query before searching in the search documentation tool


## Type of change

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

## 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.

before:
<img width="894" height="157" alt="image"
src="https://github.com/user-attachments/assets/83871ee5-511e-4432-8b99-39e803759f63"
/>

after:
<img width="1149" height="294" alt="image"
src="https://github.com/user-attachments/assets/f9617d7a-6d48-4ca1-ad1c-2181e16c1f3d"
/>


test on rails console:
<img width="2094" height="380" alt="image"
src="https://github.com/user-attachments/assets/159fdaa5-8808-49d2-be5d-304d69fa97f7"
/>


## 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-02-09 17:25:11 +05:30
Shivam MishraandGitHub 67112647e8 fix: escape special characters in Linear GraphQL queries (#13490)
Creating a Linear issue from Chatwoot fails with a GraphQL parse error
when the title, description, or search term contains double quotes. For
example, a description like `the sender is "Bot"` produces this broken
query:

```graphql
issueCreate(input: { description: "the sender is "Bot"" })
```

Linear's API rejects this with `Syntax Error: Expected ":", found
String`. This affects issue creation, issue linking, and issue search —
any flow where user-provided text is interpolated into a GraphQL query.

The `graphql_value` helper was only escaping newlines (`\n`) but not
quotes, backslashes, or other characters that are meaningful inside a
GraphQL string literal. On top of that, `issue_link` and `search_issue`
bypassed `graphql_value` entirely, using raw string interpolation
instead.

The fix replaces the manual `gsub` escaping with Ruby's `to_json`, which
produces a properly escaped, double-quoted string that handles all
special characters. This is a minimal, well-understood substitution —
`to_json` on a Ruby string returns a valid JSON string literal, which is
also a valid GraphQL string literal since GraphQL uses the same escaping
rules. The `issue_link` mutation and `search_issue` query are updated to
route their parameters through `graphql_value` instead of raw
interpolation.

The `team_entities_query` and `linked_issues` methods in `queries.rb`
also use raw interpolation, but their inputs are system-generated IDs
and URLs rather than user-provided text, so they're left as-is to keep
this change focused.
2026-02-09 16:18:04 +05:30
Tanmay Deep SharmaandGitHub 04c456e0a3 fix: handle 404 errors gracefully in avatar download job (#13491)
## Description

Fixes `Avatar::AvatarFromUrlJob` logging 404 errors as ERROR when
avatars don't exist

## Type of change

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


## 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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Small logging-only behavior change that doesn’t affect attachment flow
or persisted data beyond existing sync-attribute updates.
> 
> **Overview**
> Updates `Avatar::AvatarFromUrlJob` error handling to treat
`Down::NotFound` (404/missing avatar URL) as a non-error: it now logs an
INFO message instead of logging as ERROR.
> 
> Other `Down::Error` failures continue to be logged as ERROR, and the
job still runs `update_avatar_sync_attributes` in `ensure`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
675f41041ae3dd4ead6e0dee5f1586dcad9750cd. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-09 13:27:23 +05:30
116 changed files with 3195 additions and 751 deletions
+2
View File
@@ -94,6 +94,7 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
.codex/
CLAUDE.local.md
# Histoire deployment
@@ -101,3 +102,4 @@ CLAUDE.local.md
.histoire
.pnpm-store/*
local/
Procfile.worktree
+16
View File
@@ -4,6 +4,11 @@
- **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch`
@@ -50,6 +55,13 @@
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
## Codex Worktree Workflow
- Use a separate git worktree + branch per task to keep changes isolated.
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
@@ -86,3 +98,7 @@ Practical checklist for any change impacting core logic or public APIs
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
## Branding / White-labeling note
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
+3 -1
View File
@@ -191,12 +191,14 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.7.0'
gem 'ai-agents'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
# OpenTelemetry for LLM observability
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
+17 -15
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
ai-agents (0.9.0)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -186,6 +186,7 @@ GEM
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -297,7 +298,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.13.1)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -308,12 +309,12 @@ GEM
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.2.1)
faraday-retry (2.4.0)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
@@ -464,7 +465,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.13.2)
json (2.18.1)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -539,7 +540,7 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.0.4)
marcel (1.1.0)
maxminddb (0.1.22)
meta_request (0.8.5)
rack-contrib (>= 1.1, < 3)
@@ -558,12 +559,12 @@ GEM
multi_json (1.15.0)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.3.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.6.0)
uri
net-http (0.9.1)
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.20)
@@ -824,7 +825,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.8.2)
ruby_llm (1.9.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -968,7 +969,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.0.4)
uri (1.1.1)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1003,7 +1004,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.17)
zeitwerk (2.7.4)
PLATFORMS
arm64-darwin-20
@@ -1023,7 +1024,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.7.0)
ai-agents
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1037,6 +1038,7 @@ DEPENDENCIES
bullet
bundle-audit
byebug
cld3 (~> 3.7)
climate_control
commonmarker
csv-safe
@@ -70,6 +70,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
@@ -66,7 +66,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def destroy
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
if @inbox.present?
# Invalidate cache immediately so frontends don't serve stale data
# while the async DeleteObjectJob is still in the queue.
Current.account.update_cache_key('inbox')
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip)
end
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end
@@ -35,7 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
return head :too_many_requests if conversation.blank?
return head :payment_required unless conversation.account.email_transcript_enabled?
return head :too_many_requests unless conversation.account.within_email_rate_limit?
send_transcript_email
head :ok
@@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
def create
@user = User.from_email(params[:email])
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
@user&.send_reset_password_instructions
build_response(I18n.t('messages.reset_password'), 200)
end
def update
@@ -84,6 +84,15 @@ class CacheEnabledApiClient extends ApiClient {
return response;
}
async clearDataInCache() {
try {
await this.dataManager.initDb();
await this.dataManager.db.clear(this.cacheModelName);
} catch {
// Ignore error
}
}
async validateCacheKey(cacheKeyFromApi) {
if (!this.dataManager.db) {
await this.dataManager.initDb();
@@ -39,7 +39,6 @@ const policyA = withCount({
description: 'Distributes conversations evenly among available agents',
assignmentOrder: 'round_robin',
conversationPriority: 'high',
enabled: true,
inboxes: [mockInboxes[0], mockInboxes[1]],
isFetchingInboxes: false,
});
@@ -50,7 +49,6 @@ const policyB = withCount({
description: 'Assigns based on capacity and workload',
assignmentOrder: 'capacity_based',
conversationPriority: 'medium',
enabled: true,
inboxes: [mockInboxes[2], mockInboxes[3]],
isFetchingInboxes: false,
});
@@ -61,7 +59,6 @@ const emptyPolicy = withCount({
description: 'Policy with no assigned inboxes',
assignmentOrder: 'manual',
conversationPriority: 'low',
enabled: false,
inboxes: [],
isFetchingInboxes: false,
});
@@ -15,7 +15,6 @@ const props = defineProps({
assignmentOrder: { type: String, default: '' },
conversationPriority: { type: String, default: '' },
assignedInboxCount: { type: Number, default: 0 },
enabled: { type: Boolean, default: false },
inboxes: { type: Array, default: () => [] },
isFetchingInboxes: { type: Boolean, default: false },
});
@@ -65,22 +64,6 @@ const handleFetchInboxes = () => {
{{ name }}
</h3>
<div class="flex items-center gap-2">
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
<span
class="text-xs"
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
>
{{
enabled
? t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
)
: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
)
}}
</span>
</div>
<CardPopover
:title="
t(
@@ -19,11 +19,15 @@ defineProps({
},
});
const emit = defineEmits(['delete']);
const emit = defineEmits(['delete', 'navigate']);
const handleDelete = itemId => {
emit('delete', itemId);
};
const handleNavigate = item => {
emit('navigate', item);
};
</script>
<template>
@@ -47,7 +51,11 @@ const handleDelete = itemId => {
:key="item.id"
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
>
<div class="flex items-center gap-2 col-span-2">
<button
type="button"
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
@click="handleNavigate(item)"
>
<Icon
v-if="item.icon"
:icon="item.icon"
@@ -61,10 +69,16 @@ const handleDelete = itemId => {
:size="20"
rounded-full
/>
<span class="text-sm text-n-slate-12 truncate min-w-0">
<span
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
>
{{ item.name }}
</span>
</div>
<Icon
icon="i-lucide-external-link"
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</button>
<div class="flex items-start gap-2 col-span-1">
<span
@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
@@ -15,6 +15,9 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', {
},
});
// The model value is in seconds (for the backend/DB)
// DurationInput works in minutes internally
// We need to convert between seconds and minutes
const fairDistributionWindow = defineModel('fairDistributionWindow', {
type: Number,
default: 3600,
@@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
const windowUnit = ref(DURATION_UNITS.MINUTES);
// Convert seconds to minutes for DurationInput
const windowInMinutes = computed({
get() {
return Math.floor((fairDistributionWindow.value || 0) / 60);
},
set(minutes) {
fairDistributionWindow.value = minutes * 60;
},
});
// Detect unit based on minutes (converted from seconds)
const detectUnit = minutes => {
const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES;
@@ -34,7 +48,7 @@ const detectUnit = minutes => {
};
onMounted(() => {
windowUnit.value = detectUnit(fairDistributionWindow.value);
windowUnit.value = detectUnit(windowInMinutes.value);
});
</script>
@@ -73,9 +87,9 @@ onMounted(() => {
<div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<!-- allow 10 mins to 999 days -->
<!-- allow 10 mins to 999 days (in minutes) -->
<DurationInput
v-model:model-value="fairDistributionWindow"
v-model:model-value="windowInMinutes"
v-model:unit="windowUnit"
:min="10"
:max="1438560"
@@ -1,4 +1,6 @@
<script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({
id: {
type: String,
@@ -16,12 +18,22 @@ const props = defineProps({
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
disabledMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => {
if (!props.isActive) {
if (!props.isActive && !props.disabled) {
emit('select', props.id);
}
};
@@ -29,9 +41,11 @@ const handleChange = () => {
<template>
<div
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
class="relative rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]"
@click="handleChange"
>
@@ -41,6 +55,7 @@ const handleChange = () => {
:checked="isActive"
:value="id"
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
@@ -49,11 +64,23 @@ const handleChange = () => {
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<span
v-if="disabled"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
>
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
)
}}
</span>
</div>
<p class="text-sm text-n-slate-11">
{{ description }}
{{ disabled && disabledMessage ? disabledMessage : description }}
</p>
</div>
</div>
@@ -6,7 +6,6 @@ const policyName = ref('Round Robin Policy');
const description = ref(
'Distributes conversations evenly among available agents'
);
const enabled = ref(true);
</script>
<template>
@@ -19,13 +18,10 @@ const enabled = ref(true);
<BaseInfo
v-model:policy-name="policyName"
v-model:description="description"
v-model:enabled="enabled"
name-label="Policy Name"
name-placeholder="Enter policy name"
description-label="Description"
description-placeholder="Enter policy description"
status-label="Status"
status-placeholder="Active"
/>
</div>
</Variant>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { reactive, ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
@@ -59,6 +59,23 @@ const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const formState = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
const clearFormState = () => {
Object.assign(formState, {
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
};
const contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
@@ -140,12 +157,14 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
if (!inbox) clearFormState();
resetContacts();
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
clearFormState();
};
const closeCompose = () => {
@@ -160,6 +179,12 @@ const closeCompose = () => {
emit('close');
};
const discardCompose = () => {
clearFormState();
formState.message = '';
closeCompose();
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
@@ -171,7 +196,7 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
closeCompose();
discardCompose();
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
return true; // Return success
} catch (error) {
@@ -193,7 +218,11 @@ watch(
(currentContact, previousContact) => {
if (currentContact && props.contactId) {
// Reset on contact change
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
if (currentContact?.id !== previousContact?.id) {
clearSelectedContact();
clearFormState();
formState.message = '';
}
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(
@@ -265,6 +294,7 @@ useKeyboardEvents(keyboardEvents);
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:form-state="formState"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
@@ -285,7 +315,7 @@ useKeyboardEvents(keyboardEvents);
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
@discard="discardCompose"
/>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { reactive, ref, computed } from 'vue';
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -37,6 +37,7 @@ const props = defineProps({
contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false },
formState: { type: Object, required: true },
});
const emit = defineEmits([
@@ -57,13 +58,13 @@ const showBccEmailsDropdown = ref(false);
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
const state = reactive({
const state = props.formState || {
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
};
const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
@@ -43,6 +43,7 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import { useBranding } from 'shared/composables/useBranding';
/**
* @typedef {Object} Attachment
@@ -143,6 +144,7 @@ const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
const { replaceInstallationName } = useBranding();
/**
* Computes the message variant based on props
@@ -389,13 +391,17 @@ const shouldRenderMessage = computed(() => {
const isUnsupported = props.contentAttributes?.isUnsupported;
const isAnIntegrationMessage =
props.contentType === CONTENT_TYPES.INTEGRATIONS;
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
const hasExternalError = !!props.contentAttributes?.externalError;
return (
hasAttachments ||
props.content ||
isEmailContentType ||
isUnsupported ||
isAnIntegrationMessage
isAnIntegrationMessage ||
isFailedMessage ||
hasExternalError
);
});
@@ -472,7 +478,7 @@ const avatarInfo = computed(() => {
const avatarTooltip = computed(() => {
if (props.contentAttributes?.externalEcho) {
return t('CONVERSATION.NATIVE_APP_ADVISORY');
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
}
if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
@@ -12,11 +12,16 @@ defineProps({
const emit = defineEmits(['retry']);
const { orientation, status, createdAt } = useMessageContext();
const { orientation, status, createdAt, content, attachments } =
useMessageContext();
const { t } = useI18n();
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
const canRetry = computed(() => {
const hasContent = content.value !== null;
const hasAttachments = attachments.value && attachments.value.length > 0;
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
});
</script>
<template>
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useWindowSize, useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -50,6 +51,18 @@ const isRTL = useMapGetter('accounts/isRTL');
const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768);
const accountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const hasAdvancedAssignment = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
);
});
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -584,12 +597,16 @@ const menuItems = computed(() => {
icon: 'i-lucide-users',
to: accountScopedRoute('settings_teams_list'),
},
{
name: 'Settings Agent Assignment',
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
icon: 'i-lucide-user-cog',
to: accountScopedRoute('assignment_policy_index'),
},
...(hasAdvancedAssignment.value
? [
{
name: 'Settings Agent Assignment',
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
icon: 'i-lucide-user-cog',
to: accountScopedRoute('assignment_policy_index'),
},
]
: []),
{
name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'),
@@ -145,6 +145,7 @@ const {
isConversationSelected,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions();
@@ -859,6 +860,7 @@ provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
@@ -10,6 +10,7 @@ export default {
'assignAgent',
'assignTeam',
'assignLabels',
'removeLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
@@ -63,6 +64,7 @@ export default {
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@remove-label="removeLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@@ -28,7 +28,10 @@ import { useAlert } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
@@ -86,6 +89,7 @@ const props = defineProps({
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
conversationId: { type: Number, default: null },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
@@ -396,7 +400,14 @@ function openFileBrowser() {
}
function handleCopilotClick() {
showSelectionMenu.value = !showSelectionMenu.value;
const isOpening = !showSelectionMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'inline',
});
}
showSelectionMenu.value = isOpening;
}
function handleClickOutside(event) {
@@ -819,7 +830,13 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
</script>
<template>
<div ref="editorRoot" class="relative w-full">
<div
ref="editorRoot"
class="relative w-full"
:class="{
'opacity-50 cursor-not-allowed pointer-events-none': disabled,
}"
>
<TagAgents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@@ -122,6 +122,10 @@ export default {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
},
emits: [
'replaceText',
@@ -130,7 +134,7 @@ export default {
'selectContentTemplate',
'toggleQuotedReply',
],
setup() {
setup(props) {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
@@ -139,6 +143,9 @@ export default {
const keyboardEvents = {
'$mod+Alt+KeyA': {
action: () => {
// Skip if editor is disabled (e.g., WhatsApp 24-hour window expired)
if (props.isEditorDisabled) return;
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
@@ -146,7 +153,7 @@ export default {
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
uploadTriggerButton.click();
if (uploadTriggerButton) uploadTriggerButton.click();
},
allowOnFocusedInput: true,
},
@@ -177,9 +184,11 @@ export default {
};
},
showAttachButton() {
if (this.isEditorDisabled) return false;
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
if (this.isEditorDisabled) return false;
if (this.isALineChannel) {
return false;
}
@@ -197,6 +206,7 @@ export default {
);
},
showAudioPlayStopButton() {
if (this.isEditorDisabled) return false;
return this.showAudioRecorder && this.isRecordingAudio;
},
isInstagramDM() {
@@ -236,6 +246,7 @@ export default {
}
},
showMessageSignatureButton() {
if (this.isEditorDisabled) return false;
return !this.isOnPrivateNote;
},
sendWithSignature() {
@@ -280,6 +291,7 @@ export default {
<div class="flex justify-between p-3" :class="wrapClass">
<div class="left-wrap">
<NextButton
v-if="!isEditorDisabled"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="i-ph-smiley-sticker"
slate
@@ -288,6 +300,7 @@ export default {
@click="toggleEmojiPicker"
/>
<FileUpload
v-if="showAttachButton"
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
@@ -2,8 +2,10 @@
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
@@ -31,6 +33,14 @@ export default {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
default: null,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -69,7 +79,14 @@ export default {
};
const toggleCopilotMenu = () => {
showCopilotMenu.value = !showCopilotMenu.value;
const isOpening = !showCopilotMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'top_panel',
});
}
showCopilotMenu.value = isOpening;
};
const handleClickOutside = () => {
@@ -144,7 +161,7 @@ export default {
<div class="relative">
<NextButton
ghost
:disabled="disabled"
:disabled="disabled || isEditorDisabled"
:class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
@@ -34,6 +34,7 @@ const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
@@ -203,7 +204,10 @@ const onAssignAgent = agent => {
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
closeContextMenu();
};
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
};
const onAssignTeam = team => {
@@ -379,11 +383,13 @@ const deleteConversation = () => {
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@@ -41,7 +41,10 @@ import {
truncatePreviewText,
appendQuotedTextToMessage,
} from 'dashboard/helper/quotedEmailHelper';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import {
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
@@ -136,6 +139,7 @@ export default {
newConversationModalActive: false,
showArticleSearchPopover: false,
hasRecordedAudio: false,
copilotAcceptedMessages: {},
};
},
computed: {
@@ -195,6 +199,11 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
messagePlaceHolder() {
if (this.isEditorDisabled) {
return this.isAWhatsAppChannel
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
: this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
}
return this.isPrivate
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
: this.$t('CONVERSATION.FOOTER.MSG_INPUT');
@@ -206,6 +215,7 @@ export default {
return this.maxLength - this.message.length;
},
isReplyButtonDisabled() {
if (this.isEditorDisabled) return true;
if (this.isATwitterInbox) return true;
if (this.hasAttachments || this.hasRecordedAudio) return false;
@@ -414,6 +424,13 @@ export default {
isDefaultEditorMode() {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
isEditorDisabled() {
return (
this.isAWhatsAppChannel &&
!this.isOnPrivateNote &&
!this.currentChat.can_reply
);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -508,6 +525,24 @@ export default {
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
},
methods: {
getDraftKey(
conversationId = this.conversationIdByRoute,
replyType = this.replyType
) {
return `draft-${conversationId}-${replyType}`;
},
getCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
return this.copilotAcceptedMessages[key] || '';
},
setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
this.copilotAcceptedMessages[key] = trimContent(message || '');
},
clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
delete this.copilotAcceptedMessages[key];
},
handleInsert(article) {
const { url, title } = article;
// Removing empty lines from the title
@@ -559,7 +594,7 @@ export default {
},
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const key = `draft-${conversationId}-${replyType}`;
const key = this.getDraftKey(conversationId, replyType);
const draftToSave = trimContent(this.message || '');
this.$store.dispatch('draftMessages/set', {
@@ -574,7 +609,7 @@ export default {
},
getFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
const key = this.getDraftKey();
const messageFromStore =
this.$store.getters['draftMessages/get'](key) || '';
@@ -597,7 +632,7 @@ export default {
},
removeFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
const key = this.getDraftKey();
this.$store.dispatch('draftMessages/delete', { key });
}
},
@@ -655,6 +690,9 @@ export default {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return;
// Don't handle paste if editor is disabled
if (this.isEditorDisabled) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
@@ -708,6 +746,7 @@ export default {
return;
}
if (!this.showMentions) {
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
const isOnWhatsApp =
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
@@ -717,10 +756,17 @@ export default {
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
const isOnInstagram = this.isAnInstagramChannel;
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
this.sendMessageAsMultipleMessages(this.message);
this.sendMessageAsMultipleMessages(
this.message,
copilotAcceptedMessage
);
} else {
const messagePayload = this.getMessagePayload(this.message);
this.sendMessage(messagePayload);
this.sendMessage(
messagePayload,
this.message,
copilotAcceptedMessage
);
}
if (!this.isPrivate) {
@@ -732,13 +778,53 @@ export default {
this.$emit('update:popOutReplyBox', false);
}
},
sendMessageAsMultipleMessages(message) {
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
const messages = this.getMultipleMessagesPayload(message);
messages.forEach(messagePayload => {
this.sendMessage(messagePayload);
this.sendMessage(
messagePayload,
messagePayload.message || '',
copilotAcceptedMessage
);
});
},
sendMessageAnalyticsData(isPrivate) {
sendMessageAnalyticsData(
isPrivate,
{ editorMessage = '', copilotAcceptedMessage = '' } = {}
) {
const normalizeForComparison = message => {
let normalizedMessage = message || '';
if (this.sendWithSignature && this.messageSignature && !isPrivate) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
normalizedMessage = removeSignature(
normalizedMessage,
this.messageSignature,
effectiveChannelType
);
}
return trimContent(normalizedMessage);
};
const normalizedAcceptedMessage = normalizeForComparison(
copilotAcceptedMessage
);
const normalizedEditorMessage = normalizeForComparison(editorMessage);
if (normalizedAcceptedMessage && normalizedEditorMessage) {
useTrack(CAPTAIN_EVENTS.AI_ASSISTED_MESSAGE_SENT, {
conversationId: this.conversationIdByRoute,
channelType: this.channelType,
editedBeforeSend:
normalizedAcceptedMessage !== normalizedEditorMessage,
isPrivate,
});
}
// Analytics data for message signature is enabled or not in channels
return isPrivate
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
@@ -772,7 +858,11 @@ export default {
this.confirmOnSendReply();
}
},
async sendMessage(messagePayload) {
async sendMessage(
messagePayload,
editorMessage = '',
copilotAcceptedMessage = ''
) {
try {
await this.$store.dispatch(
'createPendingMessageAndSend',
@@ -781,7 +871,10 @@ export default {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft();
this.sendMessageAnalyticsData(messagePayload.private);
this.sendMessageAnalyticsData(messagePayload.private, {
editorMessage,
copilotAcceptedMessage,
});
} catch (error) {
const errorMessage =
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
@@ -855,6 +948,7 @@ export default {
},
clearMessage() {
this.message = '';
this.clearCopilotAcceptedMessage();
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
const effectiveChannelType = getEffectiveChannelType(
@@ -1119,7 +1213,9 @@ export default {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
this.message = this.copilot.accept();
const acceptedMessage = this.copilot.accept();
this.message = acceptedMessage;
this.setCopilotAcceptedMessage(acceptedMessage);
},
},
};
@@ -1130,11 +1226,13 @@ export default {
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
<ReplyTopPanel
:mode="replyType"
:conversation-id="conversationId"
:is-reply-restricted="isReplyRestricted"
:disabled="
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
showAudioRecorderEditor
"
:is-editor-disabled="isEditorDisabled"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:popout-reply-box="popOutReplyBox"
@@ -1204,12 +1302,14 @@ export default {
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
v-model="message"
:conversation-id="conversationId"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
:disabled="isEditorDisabled"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
@@ -1285,6 +1385,7 @@ export default {
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:is-editor-disabled="isEditorDisabled"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
@@ -31,7 +31,10 @@ const assignedAgent = computed({
},
set(agent) {
const agentId = agent ? agent.id : null;
store.dispatch('setCurrentChatAssignee', agent);
store.dispatch('setCurrentChatAssignee', {
conversationId: currentChat.value?.id,
assignee: agent,
});
store.dispatch('assignAgent', {
conversationId: currentChat.value?.id,
agentId,
@@ -53,6 +53,10 @@ export default {
type: String,
default: null,
},
conversationLabels: {
type: Array,
default: () => [],
},
conversationUrl: {
type: String,
default: '',
@@ -70,6 +74,7 @@ export default {
'assignAgent',
'assignTeam',
'assignLabel',
'removeLabel',
'deleteConversation',
'close',
],
@@ -334,8 +339,16 @@ export default {
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
@@ -1,5 +1,6 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
option: {
@@ -22,7 +23,9 @@ defineProps({
class="flex-shrink-0"
/>
<span
v-if="variant === 'label' && option.color"
v-if="
(variant === 'label' || variant === 'label-assigned') && option.color
"
class="label-pill flex-shrink-0"
:style="{ backgroundColor: option.color }"
/>
@@ -37,6 +40,11 @@ defineProps({
<p class="menu-label truncate min-w-0 flex-1">
{{ option.label }}
</p>
<Icon
v-if="variant === 'label-assigned'"
icon="i-lucide-check"
class="flex-shrink-0 size-3.5 mr-1"
/>
</div>
</template>
@@ -2,6 +2,7 @@
// components
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useBranding } from 'shared/composables/useBranding';
// composables
import { useCaptain } from 'dashboard/composables/useCaptain';
@@ -34,8 +35,9 @@ export default {
},
setup() {
const { captainTasksEnabled } = useCaptain();
const { replaceInstallationName } = useBranding();
return { captainTasksEnabled };
return { captainTasksEnabled, replaceInstallationName };
},
data() {
return {
@@ -228,7 +230,9 @@ export default {
<div class="sender--info has-tooltip" data-original-title="null">
<Avatar
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
content: replaceInstallationName(
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
@@ -0,0 +1,12 @@
export const CAPTAIN_ERROR_TYPES = Object.freeze({
ABORTED: 'aborted',
API_ERROR: 'api_error',
HTTP_PREFIX: 'http_',
ABORT_ERROR: 'AbortError',
CANCELED_ERROR: 'CanceledError',
});
export const CAPTAIN_GENERATION_FAILURE_REASONS = Object.freeze({
EMPTY_RESPONSE: 'empty_response',
EXCEPTION: 'exception',
});
@@ -102,6 +102,28 @@ export function useBulkActions() {
}
}
// Only used in context menu
async function onRemoveLabels(labelsToRemove, conversationId = null) {
try {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: conversationId || selectedConversations.value,
labels: {
remove: labelsToRemove,
},
});
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
labelName: labelsToRemove[0],
conversationId,
})
);
} catch (err) {
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
}
}
async function onAssignTeamsForBulk(team) {
try {
await store.dispatch('bulkActions/process', {
@@ -189,6 +211,7 @@ export function useBulkActions() {
isConversationSelected,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
};
@@ -11,6 +11,7 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
import { CAPTAIN_ERROR_TYPES } from 'dashboard/composables/captain/constants';
export function useCaptain() {
const store = useStore();
@@ -69,7 +70,10 @@ export function useCaptain() {
* @param {Error} error - The error object from the API call.
*/
const handleAPIError = error => {
if (error.name === 'AbortError' || error.name === 'CanceledError') {
if (
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return;
}
const errorMessage =
@@ -78,6 +82,24 @@ export function useCaptain() {
useAlert(errorMessage);
};
/**
* Classifies API error types for downstream analytics.
* @param {Error} error
* @returns {string}
*/
const getErrorType = error => {
if (
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return CAPTAIN_ERROR_TYPES.ABORTED;
}
if (error.response?.status) {
return `${CAPTAIN_ERROR_TYPES.HTTP_PREFIX}${error.response.status}`;
}
return CAPTAIN_ERROR_TYPES.API_ERROR;
};
// === Task Methods ===
/**
* Rewrites content with a specific operation.
@@ -103,7 +125,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
return { message: '', errorType: getErrorType(error) };
}
};
@@ -125,7 +147,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
return { message: '', errorType: getErrorType(error) };
}
};
@@ -147,7 +169,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
return { message: '', errorType: getErrorType(error) };
}
};
@@ -171,7 +193,11 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext: updatedContext };
} catch (error) {
handleAPIError(error);
return { message: '', followUpContext };
return {
message: '',
followUpContext,
errorType: getErrorType(error),
};
}
};
@@ -3,6 +3,10 @@ import { useCaptain } from 'dashboard/composables/useCaptain';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
CAPTAIN_ERROR_TYPES,
CAPTAIN_GENERATION_FAILURE_REASONS,
} from 'dashboard/composables/captain/constants';
// Actions that map to REWRITE events (with operation attribute)
const REWRITE_ACTIONS = [
@@ -52,6 +56,20 @@ function buildPayload(action, conversationId, followUpCount = undefined) {
return payload;
}
function trackGenerationFailure({
action,
conversationId,
followUpCount = undefined,
stage,
reason,
}) {
useTrack(CAPTAIN_EVENTS.GENERATION_FAILED, {
...buildPayload(action, conversationId, followUpCount),
stage,
reason,
});
}
/**
* Composable for managing Copilot reply generation state and actions.
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
@@ -146,7 +164,8 @@ export function useCopilotReply() {
// Reset without tracking dismiss (starting new action)
reset(false);
abortController.value = new AbortController();
const requestController = new AbortController();
abortController.value = requestController;
isGenerating.value = true;
isContentReady.value = false;
currentAction.value = action;
@@ -154,28 +173,66 @@ export function useCopilotReply() {
trackedConversationId.value = conversationId.value;
try {
const { message: content, followUpContext: newContext } =
await processEvent(action, data, {
signal: abortController.value.signal,
});
const {
message: content,
followUpContext: newContext,
errorType,
} = await processEvent(action, data, {
signal: requestController.signal,
});
if (!abortController.value?.signal.aborted) {
generatedContent.value = content;
followUpContext.value = newContext;
if (content) {
showEditor.value = true;
// Track "Used" event on successful generation
const eventKey = `${getEventPrefix(action)}_USED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(action, trackedConversationId.value)
);
if (requestController.signal.aborted) return;
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
if (abortController.value === requestController) {
isGenerating.value = false;
}
isGenerating.value = false;
return;
}
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
generatedContent.value = content;
followUpContext.value = newContext;
if (content) {
showEditor.value = true;
// Track "Used" event on successful generation
const eventKey = `${getEventPrefix(action)}_USED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(action, trackedConversationId.value)
);
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: errorType,
});
} else {
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
});
}
isGenerating.value = false;
} catch (error) {
if (
requestController.signal.aborted ||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return;
}
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
});
isGenerating.value = false;
} finally {
if (abortController.value === requestController) {
abortController.value = null;
}
}
}
@@ -187,7 +244,8 @@ export function useCopilotReply() {
async function sendFollowUp(message) {
if (!followUpContext.value || !message.trim()) return;
abortController.value = new AbortController();
const requestController = new AbortController();
abortController.value = requestController;
isGenerating.value = true;
isContentReady.value = false;
@@ -198,24 +256,65 @@ export function useCopilotReply() {
followUpCount.value += 1;
try {
const { message: content, followUpContext: updatedContext } =
await followUp({
followUpContext: followUpContext.value,
message,
signal: abortController.value.signal,
});
const {
message: content,
followUpContext: updatedContext,
errorType,
} = await followUp({
followUpContext: followUpContext.value,
message,
signal: requestController.signal,
});
if (!abortController.value?.signal.aborted) {
if (content) {
generatedContent.value = content;
followUpContext.value = updatedContext;
showEditor.value = true;
if (requestController.signal.aborted) return;
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
if (abortController.value === requestController) {
isGenerating.value = false;
}
isGenerating.value = false;
return;
}
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
if (content) {
generatedContent.value = content;
followUpContext.value = updatedContext;
showEditor.value = true;
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: errorType,
});
} else {
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
});
}
isGenerating.value = false;
} catch (error) {
if (
requestController.signal.aborted ||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return;
}
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
});
isGenerating.value = false;
} finally {
if (abortController.value === requestController) {
abortController.value = null;
}
}
}
+2
View File
@@ -2,6 +2,7 @@ export const FEATURE_FLAGS = {
AGENT_BOTS: 'agent_bots',
AGENT_MANAGEMENT: 'agent_management',
ASSIGNMENT_V2: 'assignment_v2',
ADVANCED_ASSIGNMENT: 'advanced_assignment',
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
@@ -56,4 +57,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.SAML,
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
];
@@ -85,6 +85,11 @@ export const PORTALS_EVENTS = Object.freeze({
});
export const CAPTAIN_EVENTS = Object.freeze({
// Editor funnel events
EDITOR_AI_MENU_OPENED: 'Captain: Editor AI menu opened',
GENERATION_FAILED: 'Captain: Generation failed',
AI_ASSISTED_MESSAGE_SENT: 'Captain: AI-assisted message sent',
// Rewrite events (with operation attribute in payload)
REWRITE_USED: 'Captain: Rewrite used',
REWRITE_APPLIED: 'Captain: Rewrite applied',
@@ -174,6 +174,10 @@
"SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}",
"FAILED": "Couldn't assign label. Please try again."
},
"LABEL_REMOVAL": {
"SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}",
"FAILED": "Couldn't remove label. Please try again."
},
"TEAM_ASSIGNMENT": {
"SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}",
"FAILED": "Couldn't assign team. Please try again."
@@ -186,6 +190,8 @@
"DISABLE_SIGN_TOOLTIP": "Disable signature",
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
@@ -766,6 +766,53 @@
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
},
"ASSIGNMENT": {
"TITLE": "Conversation Assignment",
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
"DEFAULT_RULES_TITLE": "Default assignment rules",
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
"DEFAULT_RULE_1": "Earliest created conversations first",
"DEFAULT_RULE_2": "Round robin distribution",
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
"USING_POLICY": "Using custom assignment policy for this inbox",
"CUSTOMIZE_POLICY": "Customize with assignment policy",
"DELETE_POLICY": "Delete policy",
"POLICY_LABEL": "Assignment policy",
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
"POLICY_STATUS": {
"ACTIVE": "Active",
"INACTIVE": "Inactive"
},
"PRIORITY": {
"EARLIEST_CREATED": "Earliest created",
"LONGEST_WAITING": "Longest waiting"
},
"METHOD": {
"ROUND_ROBIN": "Round robin",
"BALANCED": "Balanced assignment"
},
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
"DEFAULT_POLICY_LINKED": "Default policy linked",
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
"LINK_EXISTING_POLICY": "Link existing policy",
"CREATE_NEW_POLICY": "Create new policy",
"NO_POLICIES": "No assignment policies found",
"VIEW_ALL_POLICIES": "View all policies",
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
"LINK_SUCCESS": "Assignment policy linked successfully",
"LINK_ERROR": "Failed to link assignment policy"
},
"ASSIGNMENT_POLICY": {
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
"CANCEL": "Cancel",
"CONFIRM_DELETE": "Delete",
"DELETE_SUCCESS": "Assignment policy removed successfully",
"DELETE_ERROR": "Failed to remove assignment policy"
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize",
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
@@ -694,7 +694,8 @@
"CREATE_BUTTON": "Create policy",
"API": {
"SUCCESS_MESSAGE": "Assignment policy created successfully",
"ERROR_MESSAGE": "Failed to create assignment policy"
"ERROR_MESSAGE": "Failed to create assignment policy",
"INBOX_LINKED": "Inbox has been linked to the policy"
}
},
"EDIT": {
@@ -708,6 +709,12 @@
"CONFIRM_BUTTON_LABEL": "Continue",
"CANCEL_BUTTON_LABEL": "Cancel"
},
"INBOX_LINK_PROMPT": {
"TITLE": "Link inbox to policy",
"DESCRIPTION": "Would you like to link this inbox to the assignment policy?",
"LINK_BUTTON": "Link inbox",
"CANCEL_BUTTON": "Skip"
},
"API": {
"SUCCESS_MESSAGE": "Assignment policy updated successfully",
"ERROR_MESSAGE": "Failed to update assignment policy"
@@ -746,7 +753,9 @@
},
"BALANCED": {
"LABEL": "Balanced",
"DESCRIPTION": "Assign conversations based on available capacity."
"DESCRIPTION": "Assign conversations based on available capacity.",
"PREMIUM_MESSAGE": "Upgrade to access balanced assignment and agent capacity management.",
"PREMIUM_BADGE": "Premium"
}
},
"ASSIGNMENT_PRIORITY": {
@@ -832,6 +841,20 @@
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove agent from policy"
}
},
"INBOX_LIMIT_API": {
"ADD": {
"SUCCESS_MESSAGE": "Inbox limit added successfully",
"ERROR_MESSAGE": "Failed to add inbox limit"
},
"UPDATE": {
"SUCCESS_MESSAGE": "Inbox limit updated successfully",
"ERROR_MESSAGE": "Failed to update inbox limit"
},
"DELETE": {
"SUCCESS_MESSAGE": "Inbox limit deleted successfully",
"ERROR_MESSAGE": "Failed to delete inbox limit"
}
}
},
"FORM": {
@@ -85,7 +85,10 @@ export default {
},
set(agent) {
const agentId = agent ? agent.id : null;
this.$store.dispatch('setCurrentChatAssignee', agent);
this.$store.dispatch('setCurrentChatAssignee', {
conversationId: this.currentChat.id,
assignee: agent,
});
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
@@ -1,54 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const agentAssignments = computed(() => [
{
key: 'agent_assignment_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
features: [
{
icon: 'i-lucide-circle-fading-arrow-up',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
},
{
icon: 'i-lucide-scale',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
},
{
icon: 'i-lucide-inbox',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
},
],
},
{
key: 'agent_capacity_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
description: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'),
features: [
{
icon: 'i-lucide-glass-water',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
},
{
icon: 'i-lucide-circle-minus',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
},
{
icon: 'i-lucide-users-round',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
},
],
},
]);
const accountId = computed(() => Number(route.params.accountId));
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const agentAssignments = computed(() => {
const assignments = [
{
key: 'agent_assignment_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
features: [
{
icon: 'i-lucide-circle-fading-arrow-up',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
},
{
icon: 'i-lucide-scale',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
},
{
icon: 'i-lucide-inbox',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
},
],
},
];
// Only show Agent Capacity if BOTH assignment_v2 AND advanced_assignment are enabled
// advanced_assignment identifies premium users
const hasAssignmentV2 = isFeatureEnabledonAccount.value(
accountId.value,
'assignment_v2'
);
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
accountId.value,
'advanced_assignment'
);
if (hasAssignmentV2 && hasAdvancedAssignment) {
assignments.push({
key: 'agent_capacity_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
description: t(
'ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'
),
features: [
{
icon: 'i-lucide-glass-water',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
},
{
icon: 'i-lucide-circle-minus',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
},
{
icon: 'i-lucide-users-round',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
},
],
});
}
return assignments;
});
const handleClick = key => {
router.push({ name: key });
@@ -62,7 +62,7 @@ export default {
name: 'agent_capacity_policy_index',
component: AgentCapacityIndex,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
permissions: ['administrator'],
},
},
@@ -71,7 +71,7 @@ export default {
name: 'agent_capacity_policy_create',
component: AgentCapacityCreate,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
permissions: ['administrator'],
},
},
@@ -80,7 +80,7 @@ export default {
name: 'agent_capacity_policy_edit',
component: AgentCapacityEdit,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
permissions: ['administrator'],
},
},
@@ -2,13 +2,14 @@
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
@@ -16,20 +17,50 @@ const { t } = useI18n();
const formRef = ref(null);
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const breadcrumbItems = computed(() => [
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'agent_assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
},
]);
const inboxIdFromQuery = computed(() => {
const id = route.query.inboxId;
return id ? Number(id) : null;
});
const breadcrumbItems = computed(() => {
if (inboxIdFromQuery.value) {
return [
{
label: t('INBOX_MGMT.SETTINGS'),
routeName: 'settings_inbox_show',
params: { inboxId: inboxIdFromQuery.value },
},
{
label: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'
),
},
];
}
return [
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'agent_assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
},
];
});
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
if (item.params) {
const accountId = route.params.accountId;
const inboxId = item.params.inboxId;
// Navigate using explicit path to ensure tab parameter is included
router.push(
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
);
} else {
router.push({
name: item.routeName,
});
}
};
const handleSubmit = async formState => {
@@ -45,6 +76,8 @@ const handleSubmit = async formState => {
params: {
id: policy.id,
},
// Pass inboxId to edit page to show link prompt
query: inboxIdFromQuery.value ? { inboxId: inboxIdFromQuery.value } : {},
});
} catch (error) {
useAlert(
@@ -14,6 +14,7 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue';
import InboxLinkDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/InboxLinkDialog.vue';
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
@@ -36,13 +37,46 @@ const confirmInboxDialogRef = ref(null);
// Store the policy linked to the inbox when adding a new inbox
const inboxLinkedPolicy = ref(null);
const breadcrumbItems = computed(() => [
{
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
routeName: 'agent_assignment_policy_index',
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
]);
// Inbox linking prompt from create flow
const inboxIdFromQuery = computed(() => {
const id = route.query.inboxId;
return id ? Number(id) : null;
});
const suggestedInbox = computed(() => {
if (!inboxIdFromQuery.value || !inboxes.value) return null;
return inboxes.value.find(inbox => inbox.id === inboxIdFromQuery.value);
});
const isLinkingInbox = ref(false);
const dismissInboxLinkPrompt = () => {
router.replace({
name: route.name,
params: route.params,
query: {},
});
};
const breadcrumbItems = computed(() => {
if (inboxIdFromQuery.value) {
return [
{
label: t('INBOX_MGMT.SETTINGS'),
routeName: 'settings_inbox_show',
params: { inboxId: inboxIdFromQuery.value },
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
];
}
return [
{
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
routeName: 'agent_assignment_policy_index',
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
];
});
const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
@@ -66,22 +100,48 @@ const inboxList = computed(() =>
const formData = computed(() => ({
name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '',
enabled: selectedPolicy.value?.enabled || false,
enabled: true,
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
conversationPriority:
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
}));
const handleDeleteInbox = inboxId =>
store.dispatch('assignmentPolicies/removeInboxPolicy', {
policyId: selectedPolicy.value?.id,
inboxId,
});
const handleDeleteInbox = async inboxId => {
try {
await store.dispatch('assignmentPolicies/removeInboxPolicy', {
policyId: selectedPolicy.value?.id,
inboxId,
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.ERROR_MESSAGE`));
}
};
const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const handleBreadcrumbClick = ({ routeName, params }) => {
if (params) {
const accountId = route.params.accountId;
const inboxId = params.inboxId;
// Navigate using explicit path to ensure tab parameter is included
router.push(
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
);
} else {
router.push({ name: routeName });
}
};
const handleNavigateToInbox = inbox => {
router.push({
name: 'settings_inbox_show',
params: {
accountId: route.params.accountId,
inboxId: inbox.id,
},
});
};
const setInboxPolicy = async (inboxId, policyId) => {
try {
@@ -122,6 +182,26 @@ const handleAddInbox = async inbox => {
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
};
const handleLinkSuggestedInbox = async () => {
if (!suggestedInbox.value) return;
isLinkingInbox.value = true;
const inbox = {
id: suggestedInbox.value.id,
name: suggestedInbox.value.name,
};
await handleAddInbox(inbox);
// Clear the query param after linking
router.replace({
name: route.name,
params: route.params,
query: {},
});
isLinkingInbox.value = false;
};
const handleConfirmAddInbox = async inboxId => {
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
@@ -155,6 +235,11 @@ const handleSubmit = async formState => {
const fetchPolicyData = async () => {
if (!routeId.value) return;
// Fetch inboxes if not already loaded (needed for inbox link prompt)
if (!inboxes.value?.length) {
store.dispatch('inboxes/get');
}
// Fetch policy if not available
if (!selectedPolicy.value?.id)
await store.dispatch('assignmentPolicies/show', routeId.value);
@@ -186,6 +271,7 @@ watch(routeId, fetchPolicyData, { immediate: true });
@submit="handleSubmit"
@add-inbox="handleAddInbox"
@delete-inbox="handleDeleteInbox"
@navigate-to-inbox="handleNavigateToInbox"
/>
</template>
@@ -193,5 +279,12 @@ watch(routeId, fetchPolicyData, { immediate: true });
ref="confirmInboxDialogRef"
@add="handleConfirmAddInbox"
/>
<InboxLinkDialog
:inbox="suggestedInbox"
:is-linking="isLinkingInbox"
@link="handleLinkSuggestedInbox"
@dismiss="dismissInboxLinkPrompt"
/>
</SettingsLayout>
</template>
@@ -92,43 +92,68 @@ const formData = computed(() => ({
const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const handleDeleteUser = agentId => {
store.dispatch('agentCapacityPolicies/removeUser', {
policyId: selectedPolicyId.value,
userId: agentId,
});
const handleDeleteUser = async agentId => {
try {
await store.dispatch('agentCapacityPolicies/removeUser', {
policyId: selectedPolicyId.value,
userId: agentId,
});
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.ERROR_MESSAGE`));
}
};
const handleAddUser = agent => {
store.dispatch('agentCapacityPolicies/addUser', {
policyId: selectedPolicyId.value,
userData: { id: agent.id, capacity: 20 },
});
const handleAddUser = async agent => {
try {
await store.dispatch('agentCapacityPolicies/addUser', {
policyId: selectedPolicyId.value,
userData: { id: agent.id, capacity: 20 },
});
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.ERROR_MESSAGE`));
}
};
const handleDeleteInboxLimit = limitId => {
store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
policyId: selectedPolicyId.value,
limitId,
});
const handleDeleteInboxLimit = async limitId => {
try {
await store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
policyId: selectedPolicyId.value,
limitId,
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.ERROR_MESSAGE`));
}
};
const handleAddInboxLimit = limit => {
store.dispatch('agentCapacityPolicies/createInboxLimit', {
policyId: selectedPolicyId.value,
limitData: {
inboxId: limit.inboxId,
conversationLimit: limit.conversationLimit,
},
});
const handleAddInboxLimit = async limit => {
try {
await store.dispatch('agentCapacityPolicies/createInboxLimit', {
policyId: selectedPolicyId.value,
limitData: {
inboxId: limit.inboxId,
conversationLimit: limit.conversationLimit,
},
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.ERROR_MESSAGE`));
}
};
const handleLimitChange = limit => {
store.dispatch('agentCapacityPolicies/updateInboxLimit', {
policyId: selectedPolicyId.value,
limitId: limit.id,
limitData: { conversationLimit: limit.conversationLimit },
});
const handleLimitChange = async limit => {
try {
await store.dispatch('agentCapacityPolicies/updateInboxLimit', {
policyId: selectedPolicyId.value,
limitId: limit.id,
limitData: { conversationLimit: limit.conversationLimit },
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.ERROR_MESSAGE`));
}
};
const handleSubmit = async formState => {
@@ -1,7 +1,8 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useConfig } from 'dashboard/composables/useConfig';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
@@ -23,7 +24,6 @@ const props = defineProps({
default: () => ({
name: '',
description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -61,18 +61,24 @@ const emit = defineEmits([
'submit',
'addInbox',
'deleteInbox',
'navigateToInbox',
'validationChange',
]);
const { t } = useI18n();
const { isEnterprise } = useConfig();
const route = useRoute();
const accountId = computed(() => Number(route.params.accountId));
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const state = reactive({
name: '',
description: '',
enabled: false,
enabled: true,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -83,20 +89,42 @@ const validationState = ref({
isValid: false,
});
const createOption = (type, key, stateKey) => ({
const createOption = (
type,
key,
stateKey,
disabled = false,
disabledMessage = ''
) => ({
key,
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
isActive: state[stateKey] === key,
disabled,
disabledMessage,
});
const assignmentOrderOptions = computed(() => {
const options = OPTIONS.ORDER.filter(
key => isEnterprise || key !== 'balanced'
);
return options.map(key =>
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
accountId.value,
'advanced_assignment'
);
return OPTIONS.ORDER.map(key => {
const isBalanced = key === 'balanced';
const disabled = isBalanced && !hasAdvancedAssignment;
const disabledMessage = disabled
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_MESSAGE`)
: '';
return createOption(
'ASSIGNMENT_ORDER',
key,
'assignmentOrder',
disabled,
disabledMessage
);
});
});
const assignmentPriorityOptions = computed(() =>
@@ -131,7 +159,7 @@ const resetForm = () => {
Object.assign(state, {
name: '',
description: '',
enabled: false,
enabled: true,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -162,15 +190,10 @@ defineExpose({
<BaseInfo
v-model:policy-name="state.name"
v-model:description="state.description"
v-model:enabled="state.enabled"
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
:status-label="t(`${BASE_KEY}.FORM.STATUS.LABEL`)"
:status-placeholder="
t(`${BASE_KEY}.FORM.STATUS.${state.enabled ? 'ACTIVE' : 'INACTIVE'}`)
"
@validation-change="handleValidationChange"
/>
@@ -193,6 +216,8 @@ defineExpose({
:label="option.label"
:description="option.description"
:is-active="option.isActive"
:disabled="option.disabled"
:disabled-message="option.disabledMessage"
@select="state[section.key] = $event"
/>
</div>
@@ -251,6 +276,7 @@ defineExpose({
:is-fetching="isInboxLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
@delete="$emit('deleteInbox', $event)"
@navigate="$emit('navigateToInbox', $event)"
/>
</div>
</form>
@@ -0,0 +1,116 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
inbox: {
type: Object,
default: null,
},
isLinking: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['link', 'dismiss']);
const { t } = useI18n();
const dialogRef = ref(null);
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
if (!props.inbox) return 'i-lucide-inbox';
return getInboxIconByType(
props.inbox.channelType,
props.inbox.medium,
'line'
);
});
const openDialog = () => {
dialogRef.value?.open();
};
const closeDialog = () => {
dialogRef.value?.close();
};
const handleConfirm = () => {
emit('link');
};
const handleClose = () => {
emit('dismiss');
};
watch(
() => props.inbox,
async newInbox => {
if (newInbox) {
await nextTick();
openDialog();
} else {
closeDialog();
}
},
{ immediate: true }
);
defineExpose({ openDialog, closeDialog });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.TITLE'
)
"
:confirm-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.LINK_BUTTON'
)
"
:cancel-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.CANCEL_BUTTON'
)
"
:is-loading="isLinking"
@confirm="handleConfirm"
@close="handleClose"
>
<template #description>
<p class="text-sm text-n-slate-11">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.DESCRIPTION'
)
}}
</p>
</template>
<div
class="flex items-center gap-3 p-3 rounded-xl border border-n-weak bg-n-alpha-1"
>
<div
class="flex-shrink-0 size-10 rounded-lg bg-n-alpha-2 flex items-center justify-center"
>
<i :class="inboxIcon" class="text-lg text-n-slate-11" />
</div>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ inboxName }}
</span>
</div>
</div>
</Dialog>
</template>
@@ -1,122 +1,321 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { vOnClickOutside } from '@vueuse/components';
import { useVuelidate } from '@vuelidate/core';
import { minValue } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig';
import SettingsSection from '../../../../../components/SettingsSection.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import assignmentPoliciesAPI from 'dashboard/api/assignmentPolicies';
import { useI18n } from 'vue-i18n';
export default {
components: {
SettingsSection,
NextButton,
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
setup() {
const { isEnterprise } = useConfig();
});
return { v$: useVuelidate(), isEnterprise };
},
data() {
return {
selectedAgents: [],
isAgentListUpdating: false,
enableAutoAssignment: false,
maxAssignmentLimit: null,
};
},
computed: {
...mapGetters({
agentList: 'agents/getAgents',
}),
maxAssignmentLimitErrors() {
if (this.v$.maxAssignmentLimit.$error) {
return this.$t(
'INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR'
);
}
return '';
},
},
watch: {
inbox() {
this.setDefaults();
},
},
mounted() {
this.setDefaults();
},
methods: {
setDefaults() {
this.enableAutoAssignment = this.inbox.enable_auto_assignment;
this.maxAssignmentLimit =
this.inbox?.auto_assignment_config?.max_assignment_limit || null;
this.fetchAttachedAgents();
},
async fetchAttachedAgents() {
try {
const response = await this.$store.dispatch('inboxMembers/get', {
inboxId: this.inbox.id,
});
const {
data: { payload: inboxMembers },
} = response;
this.selectedAgents = inboxMembers;
} catch (error) {
// Handle error
}
},
handleEnableAutoAssignment() {
this.updateInbox();
},
async updateAgents() {
const agentList = this.selectedAgents.map(el => el.id);
this.isAgentListUpdating = true;
try {
await this.$store.dispatch('inboxMembers/create', {
inboxId: this.inbox.id,
agentList,
});
useAlert(this.$t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
}
this.isAgentListUpdating = false;
},
async updateInbox() {
try {
const payload = {
id: this.inbox.id,
formData: false,
enable_auto_assignment: this.enableAutoAssignment,
auto_assignment_config: {
max_assignment_limit: this.maxAssignmentLimit,
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
}
},
},
validations: {
selectedAgents: {
isEmpty() {
return !!this.selectedAgents.length;
},
},
maxAssignmentLimit: {
minValue: minValue(1),
},
const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { isEnterprise } = useConfig();
const selectedAgents = ref([]);
const isAgentListUpdating = ref(false);
const enableAutoAssignment = ref(false);
const maxAssignmentLimit = ref(null);
const assignmentPolicy = ref(null);
const isLoadingPolicy = ref(false);
const isDeletingPolicy = ref(false);
const showDeleteConfirmModal = ref(false);
const availablePolicies = ref([]);
const isLoadingPolicies = ref(false);
const showPolicyDropdown = ref(false);
const isLinkingPolicy = ref(false);
const agentList = computed(() => store.getters['agents/getAgents']);
const isFeatureEnabled = feature => {
const accountId = Number(route.params.accountId);
return store.getters['accounts/isFeatureEnabledonAccount'](
accountId,
feature
);
};
const hasAdvancedAssignment = computed(() => {
return isFeatureEnabled('advanced_assignment');
});
const hasAssignmentV2 = computed(() => {
return isFeatureEnabled('assignment_v2');
});
const showAdvancedAssignmentUI = computed(() => {
return hasAdvancedAssignment.value && hasAssignmentV2.value;
});
const assignmentOrderLabel = computed(() => {
if (!assignmentPolicy.value) return '';
const priority = assignmentPolicy.value.conversation_priority;
if (priority === 'earliest_created') {
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.EARLIEST_CREATED');
}
if (priority === 'longest_waiting') {
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.LONGEST_WAITING');
}
return priority;
});
const assignmentMethodLabel = computed(() => {
if (!assignmentPolicy.value) return '';
const order = assignmentPolicy.value.assignment_order;
if (order === 'round_robin') {
return t('INBOX_MGMT.ASSIGNMENT.METHOD.ROUND_ROBIN');
}
if (order === 'balanced') {
return t('INBOX_MGMT.ASSIGNMENT.METHOD.BALANCED');
}
return order;
});
// Vuelidate validation rules
const rules = {
maxAssignmentLimit: {
minValue: minValue(1),
},
};
const v$ = useVuelidate(rules, { maxAssignmentLimit });
const maxAssignmentLimitErrors = computed(() => {
if (v$.value.maxAssignmentLimit.$error) {
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR');
}
return '';
});
const fetchAttachedAgents = async () => {
try {
const response = await store.dispatch('inboxMembers/get', {
inboxId: props.inbox.id,
});
const {
data: { payload: inboxMembers },
} = response;
selectedAgents.value = inboxMembers;
} catch (error) {
// Handle error
}
};
const fetchAssignmentPolicy = async () => {
if (!props.inbox.id) return;
isLoadingPolicy.value = true;
try {
const response = await assignmentPoliciesAPI.getInboxPolicy(props.inbox.id);
assignmentPolicy.value = response.data;
} catch (error) {
// No policy attached, which is fine
assignmentPolicy.value = null;
} finally {
isLoadingPolicy.value = false;
}
};
const fetchAvailablePolicies = async () => {
isLoadingPolicies.value = true;
try {
const response = await assignmentPoliciesAPI.get();
availablePolicies.value = response.data;
} catch (error) {
availablePolicies.value = [];
} finally {
isLoadingPolicies.value = false;
}
};
const linkPolicyToInbox = async policy => {
isLinkingPolicy.value = true;
try {
await assignmentPoliciesAPI.setInboxPolicy(props.inbox.id, policy.id);
assignmentPolicy.value = policy;
showPolicyDropdown.value = false;
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_SUCCESS'));
} catch (error) {
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_ERROR'));
} finally {
isLinkingPolicy.value = false;
}
};
const navigateToAssignmentPolicies = () => {
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_index',
params: { accountId },
});
};
const policyMenuItems = computed(() => {
const items = availablePolicies.value.map(policy => ({
action: 'select_policy',
value: policy.id,
label: policy.name,
icon: 'i-lucide-zap',
policy,
}));
items.push({
action: 'view_all',
value: 'view_all',
label: t('INBOX_MGMT.ASSIGNMENT.VIEW_ALL_POLICIES'),
icon: 'i-lucide-arrow-right',
});
return items;
});
const handlePolicyMenuAction = ({ action, policy }) => {
if (action === 'select_policy' && policy) {
linkPolicyToInbox(policy);
} else if (action === 'view_all') {
navigateToAssignmentPolicies();
}
showPolicyDropdown.value = false;
};
const togglePolicyDropdown = () => {
if (!showPolicyDropdown.value && availablePolicies.value.length === 0) {
fetchAvailablePolicies();
}
showPolicyDropdown.value = !showPolicyDropdown.value;
};
const closePolicyDropdown = () => {
showPolicyDropdown.value = false;
};
const handleToggleAutoAssignment = async () => {
try {
const payload = {
id: props.inbox.id,
formData: false,
enable_auto_assignment: enableAutoAssignment.value,
};
await store.dispatch('inboxes/updateInbox', payload);
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
};
const updateAgents = async () => {
const agentListIds = selectedAgents.value.map(el => el.id);
isAgentListUpdating.value = true;
try {
await store.dispatch('inboxMembers/create', {
inboxId: props.inbox.id,
agentList: agentListIds,
});
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
}
isAgentListUpdating.value = false;
};
const updateInbox = async () => {
try {
const payload = {
id: props.inbox.id,
formData: false,
enable_auto_assignment: enableAutoAssignment.value,
auto_assignment_config: {
max_assignment_limit: maxAssignmentLimit.value,
},
};
await store.dispatch('inboxes/updateInbox', payload);
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
};
const navigateToCreatePolicy = () => {
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_create',
params: { accountId },
query: { inboxId: props.inbox.id },
});
};
const navigateToAssignmentPolicyEdit = () => {
if (!assignmentPolicy.value?.id) return;
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_edit',
params: { accountId, id: assignmentPolicy.value.id },
});
};
const navigateToBilling = () => {
const accountId = route.params.accountId;
router.push({
name: 'billing_settings_index',
params: { accountId },
});
};
const confirmDeletePolicy = () => {
showDeleteConfirmModal.value = true;
};
const cancelDeletePolicy = () => {
showDeleteConfirmModal.value = false;
};
const deleteAssignmentPolicy = async () => {
if (isDeletingPolicy.value) return;
isDeletingPolicy.value = true;
try {
await assignmentPoliciesAPI.removeInboxPolicy(props.inbox.id);
assignmentPolicy.value = null;
showDeleteConfirmModal.value = false;
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_SUCCESS'));
} catch (error) {
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_ERROR'));
} finally {
isDeletingPolicy.value = false;
}
};
const setDefaults = () => {
enableAutoAssignment.value = props.inbox.enable_auto_assignment;
maxAssignmentLimit.value =
props.inbox.auto_assignment_config?.max_assignment_limit || null;
fetchAttachedAgents();
if (showAdvancedAssignmentUI.value) {
fetchAssignmentPolicy();
fetchAvailablePolicies();
}
};
// Watch only inbox.id to avoid unnecessary refetches when other properties change
watch(() => props.inbox.id, setDefaults);
onMounted(() => {
setDefaults();
});
</script>
<template>
@@ -138,7 +337,6 @@ export default {
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
@select="v$.selectedAgents.$touch"
/>
<NextButton
@@ -152,44 +350,325 @@ export default {
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
>
<label class="w-3/4 settings-item">
<div class="flex items-center gap-2">
<input
id="enableAutoAssignment"
<!-- New UI for assignment_v2 -->
<template v-if="hasAssignmentV2">
<div class="flex items-start gap-3">
<Switch
v-model="enableAutoAssignment"
type="checkbox"
@change="handleEnableAutoAssignment"
class="flex-shrink-0 mt-0.5"
@change="handleToggleAutoAssignment"
/>
<label for="enableAutoAssignment">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
</label>
<div class="flex-grow">
<label class="text-sm text-n-slate-12 font-medium mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }}
</label>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
</p>
</div>
</div>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
</p>
</label>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="enableAutoAssignment" class="mt-6">
<!-- Policy Card - When policy is attached -->
<div
v-if="showAdvancedAssignmentUI && assignmentPolicy"
class="p-4 rounded-xl outline-1 outline-n-weak outline bg-n-solid-1 dark:bg-n-slate-1"
>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 size-12 rounded-xl bg-n-slate-3 flex items-center justify-center"
>
<span class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<div class="flex items-start justify-between gap-4 mb-4">
<div class="flex flex-col items-start">
<span class="text-base font-medium text-n-slate-12 mb-1">
{{ assignmentPolicy.name }}
</span>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.POLICY_LABEL') }}
</p>
</div>
<NextButton
icon="i-lucide-trash-2"
ghost
ruby
sm
@click="confirmDeletePolicy"
/>
</div>
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
<woot-input
v-model="maxAssignmentLimit"
type="number"
:class="{ error: v$.maxAssignmentLimit.$error }"
:error="maxAssignmentLimitErrors"
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
@blur="v$.maxAssignmentLimit.$touch"
/>
<ul class="space-y-2 mb-6">
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12">
{{ assignmentOrderLabel }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12">
{{ assignmentMethodLabel }}
</span>
</li>
</ul>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
</p>
<div class="w-full h-px my-4 bg-n-weak" />
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:disabled="v$.maxAssignmentLimit.$invalid"
@click="updateInbox"
/>
</div>
<NextButton
:label="$t('INBOX_MGMT.ASSIGNMENT.CUSTOMIZE_POLICY')"
icon="i-lucide-arrow-right"
trailing-icon
link
class="mb-2"
@click="navigateToAssignmentPolicyEdit"
/>
</div>
</div>
</div>
<!-- Default Policy - When no custom policy attached but feature enabled -->
<div
v-else-if="
showAdvancedAssignmentUI &&
!assignmentPolicy &&
!isLoadingPolicy
"
class="rounded-xl outline-1 outline-n-weak outline"
>
<!-- Default Policy Header -->
<div class="p-4">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
>
<i class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<h4 class="text-base font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_LINKED') }}
</h4>
<p class="text-sm text-n-slate-11">
{{
$t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_DESCRIPTION')
}}
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="mt-5 flex items-center gap-3">
<div
v-if="!isLoadingPolicies && availablePolicies.length > 0"
v-on-click-outside="closePolicyDropdown"
class="relative"
>
<button
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-n-brand hover:bg-n-brand/90 rounded-lg transition-colors"
@click="togglePolicyDropdown"
>
<i class="i-lucide-link text-sm" />
{{ $t('INBOX_MGMT.ASSIGNMENT.LINK_EXISTING_POLICY') }}
<i
class="i-lucide-chevron-down text-sm transition-transform"
:class="{ 'rotate-180': showPolicyDropdown }"
/>
</button>
<DropdownMenu
v-if="showPolicyDropdown"
class="top-full left-0 mt-2 min-w-72"
:menu-items="policyMenuItems"
:is-searching="isLoadingPolicies"
@action="handlePolicyMenuAction"
/>
</div>
<button
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-n-slate-12 bg-n-slate-3 dark:bg-n-slate-4 hover:bg-n-slate-4 dark:hover:bg-n-slate-5 rounded-lg transition-colors"
@click="navigateToCreatePolicy"
>
<i class="i-lucide-plus text-sm" />
{{ $t('INBOX_MGMT.ASSIGNMENT.CREATE_NEW_POLICY') }}
</button>
</div>
</div>
<!-- Default Rules Info -->
<div class="px-4 py-4 border-t border-n-weak bg-n-slate-2">
<div class="flex items-start gap-3">
<i class="i-lucide-info text-base text-n-slate-10 mt-0.5" />
<div>
<p class="text-sm text-n-slate-11 mb-2">
{{ $t('INBOX_MGMT.ASSIGNMENT.CURRENT_BEHAVIOR') }}
</p>
<ul class="space-y-1">
<li class="flex items-center gap-2">
<span
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
/>
<span class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
/>
<span class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Default Rules Card - Feature not enabled (no advanced_assignment) -->
<div
v-else-if="!showAdvancedAssignmentUI"
class="p-4 rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
>
<i class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<h4 class="text-base font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_TITLE') }}
</h4>
<p class="text-sm text-n-slate-11 mb-4">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_DESCRIPTION') }}
</p>
<ul class="space-y-2 mb-6">
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
</span>
</li>
</ul>
<div class="w-full h-px bg-n-weak my-4" />
<!-- Upgrade prompt when advanced_assignment is not enabled -->
<div v-if="!hasAdvancedAssignment">
<p class="text-sm text-n-slate-11 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.UPGRADE_PROMPT') }}
</p>
<NextButton
:label="$t('INBOX_MGMT.ASSIGNMENT.UPGRADE_TO_BUSINESS')"
icon="i-lucide-arrow-right"
trailing-icon
link
@click="navigateToBilling"
/>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<!-- Old UI for non-assignment_v2 -->
<template v-else>
<label class="w-3/4 settings-item">
<div class="flex items-center gap-2">
<input
id="enableAutoAssignment"
v-model="enableAutoAssignment"
type="checkbox"
@change="handleToggleAutoAssignment"
/>
<label for="enableAutoAssignment">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
</label>
</div>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
</p>
</label>
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
<woot-input
v-model="maxAssignmentLimit"
type="number"
:class="{ error: v$.maxAssignmentLimit.$error }"
:error="maxAssignmentLimitErrors"
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
@blur="v$.maxAssignmentLimit.$touch"
/>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
</p>
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:disabled="v$.maxAssignmentLimit.$invalid"
@click="updateInbox"
/>
</div>
</template>
</SettingsSection>
<woot-modal
v-if="showDeleteConfirmModal"
:show="showDeleteConfirmModal"
:on-close="cancelDeletePolicy"
>
<div class="p-6">
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 mb-6 ml-13">
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_MESSAGE') }}
</p>
<div class="flex justify-end gap-2">
<NextButton
color="slate"
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CANCEL')"
@click="cancelDeletePolicy"
/>
<NextButton
color="ruby"
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CONFIRM_DELETE')"
:is-loading="isDeletingPolicy"
@click="deleteAssignmentPolicy"
/>
</div>
</div>
</woot-modal>
</div>
</template>
@@ -96,7 +96,7 @@ const actions = {
data: payload,
});
if (!payload.length) {
commit(types.SET_ALL_MESSAGES_LOADED);
commit(types.SET_ALL_MESSAGES_LOADED, data.conversationId);
}
} catch (error) {
// Handle error
@@ -191,7 +191,7 @@ const actions = {
async setActiveChat({ commit, dispatch }, { data, after }) {
commit(types.SET_CURRENT_CHAT_WINDOW, data);
commit(types.CLEAR_ALL_MESSAGES_LOADED);
commit(types.CLEAR_ALL_MESSAGES_LOADED, data.id);
if (data.dataFetched === undefined) {
try {
await dispatch('fetchPreviousMessages', {
@@ -199,7 +199,7 @@ const actions = {
before: data.messages[0].id,
conversationId: data.id,
});
data.dataFetched = true;
commit(types.SET_CHAT_DATA_FETCHED, data.id);
} catch (error) {
// Ignore error
}
@@ -212,14 +212,17 @@ const actions = {
conversationId,
agentId,
});
dispatch('setCurrentChatAssignee', response.data);
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
});
} catch (error) {
// Handle error
}
},
setCurrentChatAssignee({ commit }, assignee) {
commit(types.ASSIGN_AGENT, assignee);
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
commit(types.ASSIGN_AGENT, { conversationId, assignee });
},
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
@@ -63,14 +63,18 @@ export const mutations = {
_state.allConversations = [];
_state.selectedChatId = null;
},
[types.SET_ALL_MESSAGES_LOADED](_state) {
const [chat] = getSelectedChatConversation(_state);
chat.allMessagesLoaded = true;
[types.SET_ALL_MESSAGES_LOADED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.allMessagesLoaded = true;
}
},
[types.CLEAR_ALL_MESSAGES_LOADED](_state) {
const [chat] = getSelectedChatConversation(_state);
chat.allMessagesLoaded = false;
[types.CLEAR_ALL_MESSAGES_LOADED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.allMessagesLoaded = false;
}
},
[types.CLEAR_CURRENT_CHAT_WINDOW](_state) {
_state.selectedChatId = null;
@@ -91,15 +95,24 @@ export const mutations = {
chat.messages = data;
},
[types.SET_CHAT_DATA_FETCHED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.dataFetched = true;
}
},
[types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) {
if (activeChat) {
_state.selectedChatId = activeChat.id;
}
},
[types.ASSIGN_AGENT](_state, assignee) {
const [chat] = getSelectedChatConversation(_state);
chat.meta.assignee = assignee;
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
}
},
[types.ASSIGN_TEAM](_state, { team, conversationId }) {
@@ -274,8 +287,10 @@ export const mutations = {
// Update assignee on action cable message
[types.UPDATE_ASSIGNEE](_state, payload) {
const [chat] = _state.allConversations.filter(c => c.id === payload.id);
chat.meta.assignee = payload.assignee;
const chat = getConversationById(_state)(payload.id);
if (chat) {
chat.meta.assignee = payload.assignee;
}
},
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
@@ -324,6 +324,8 @@ export const actions = {
try {
await InboxesAPI.delete(inboxId);
commit(types.default.DELETE_INBOXES, inboxId);
// Clear IndexedDB so stale inbox data isn't served on page refresh
await InboxesAPI.clearDataInCache();
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: false });
@@ -355,22 +355,26 @@ describe('#actions', () => {
axios.post.mockResolvedValue({
data: { id: 1, name: 'User' },
});
await actions.assignAgent({ commit }, { conversationId: 1, agentId: 1 });
expect(commit).toHaveBeenCalledTimes(0);
expect(commit.mock.calls).toEqual([]);
await actions.assignAgent(
{ dispatch },
{ conversationId: 1, agentId: 1 }
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
});
});
});
describe('#setCurrentChatAssignee', () => {
it('sends correct mutations if assignment is successful', async () => {
axios.post.mockResolvedValue({
data: { id: 1, name: 'User' },
});
await actions.setCurrentChatAssignee({ commit }, { id: 1, name: 'User' });
const payload = {
conversationId: 1,
assignee: { id: 1, name: 'User' },
};
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1);
expect(commit.mock.calls).toEqual([
['ASSIGN_AGENT', { id: 1, name: 'User' }],
]);
expect(commit.mock.calls).toEqual([['ASSIGN_AGENT', payload]]);
});
});
@@ -716,6 +720,64 @@ describe('#addMentions', () => {
});
});
describe('#setActiveChat', () => {
it('should commit SET_CHAT_DATA_FETCHED with conversation ID after fetch', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn().mockResolvedValue();
const data = { id: 42, messages: [{ id: 100 }] };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data, after: 99 }
);
expect(localCommit.mock.calls).toEqual([
[types.SET_CURRENT_CHAT_WINDOW, data],
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
[types.SET_CHAT_DATA_FETCHED, 42],
]);
expect(localDispatch).toHaveBeenCalledWith('fetchPreviousMessages', {
after: 99,
before: 100,
conversationId: 42,
});
});
it('should not dispatch fetchPreviousMessages if dataFetched is already set', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn();
const data = { id: 42, messages: [{ id: 100 }], dataFetched: true };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data }
);
expect(localCommit.mock.calls).toEqual([
[types.SET_CURRENT_CHAT_WINDOW, data],
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
]);
expect(localDispatch).not.toHaveBeenCalled();
});
it('should commit SET_CHAT_DATA_FETCHED by ID, not mutate the data object directly (race condition fix)', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn().mockResolvedValue();
const data = { id: 42, messages: [{ id: 100 }] };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data }
);
// The action must NOT set dataFetched on the data object directly
expect(data.dataFetched).toBeUndefined();
// Instead it commits a mutation that finds the conversation by ID in the store
expect(localCommit).toHaveBeenCalledWith(types.SET_CHAT_DATA_FETCHED, 42);
});
});
describe('#getInboxCaptainAssistantById', () => {
it('fetches inbox assistant by id', async () => {
axios.get.mockResolvedValue({
@@ -570,25 +570,84 @@ describe('#mutations', () => {
});
});
describe('#SET_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to true on selected chat', () => {
describe('#SET_CHAT_DATA_FETCHED', () => {
it('should set dataFetched to true on the conversation by ID', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: false }],
allConversations: [{ id: 1 }, { id: 2 }],
};
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
expect(state.allConversations[0].dataFetched).toBe(true);
expect(state.allConversations[1].dataFetched).toBeUndefined();
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.SET_CHAT_DATA_FETCHED](state, 999);
expect(state.allConversations[0].dataFetched).toBeUndefined();
});
it('should survive the race: SET_ALL_CONVERSATION replaces the object, then SET_CHAT_DATA_FETCHED still works', () => {
// 1. Initial state: conversation exists with dataFetched undefined
const state = {
allConversations: [{ id: 1, messages: [{ id: 'm1' }] }],
selectedChatId: 1,
};
mutations[types.SET_ALL_MESSAGES_LOADED](state);
const originalRef = state.allConversations[0];
// 2. Simulate SET_ALL_CONVERSATION replacing the object (WebSocket/polling)
// This copies dataFetched from the old object (still undefined)
mutations[types.SET_ALL_CONVERSATION](state, [
{ id: 1, name: 'refreshed', messages: [{ id: 'm2' }] },
]);
// The store now holds a NEW object, old reference is detached
const newRef = state.allConversations[0];
expect(newRef).not.toBe(originalRef);
expect(newRef.dataFetched).toBeUndefined();
// 3. SET_CHAT_DATA_FETCHED finds by ID — works on the current store object
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
expect(state.allConversations[0].dataFetched).toBe(true);
// Old detached reference is unaffected
expect(originalRef.dataFetched).toBeUndefined();
});
});
describe('#SET_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to true on the conversation by ID', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: false }, { id: 2 }],
};
mutations[types.SET_ALL_MESSAGES_LOADED](state, 1);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
expect(state.allConversations[1].allMessagesLoaded).toBeUndefined();
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.SET_ALL_MESSAGES_LOADED](state, 999);
expect(state.allConversations[0].allMessagesLoaded).toBeUndefined();
});
});
describe('#CLEAR_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to false on selected chat', () => {
it('should set allMessagesLoaded to false on the conversation by ID', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: true }],
selectedChatId: 1,
allConversations: [
{ id: 1, allMessagesLoaded: true },
{ id: 2, allMessagesLoaded: true },
],
};
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state);
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 1);
expect(state.allConversations[0].allMessagesLoaded).toBe(false);
expect(state.allConversations[1].allMessagesLoaded).toBe(true);
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1, allMessagesLoaded: true }] };
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 999);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
});
});
@@ -640,15 +699,22 @@ describe('#mutations', () => {
});
describe('#ASSIGN_AGENT', () => {
it('should assign agent to selected conversation', () => {
it('should assign agent to the correct conversation by ID', () => {
const assignee = { id: 1, name: 'Agent' };
const state = {
allConversations: [{ id: 1, meta: {} }],
selectedChatId: 1,
allConversations: [
{ id: 1, meta: {} },
{ id: 2, meta: {} },
],
selectedChatId: 2,
};
mutations[types.ASSIGN_AGENT](state, assignee);
mutations[types.ASSIGN_AGENT](state, {
conversationId: 1,
assignee,
});
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
expect(state.allConversations[1].meta.assignee).toBeUndefined();
});
});
@@ -797,6 +863,34 @@ describe('#mutations', () => {
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('resolved');
});
it('should preserve dataFetched and allMessagesLoaded during update', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
messages: [{ id: 'msg1' }],
dataFetched: true,
allMessagesLoaded: true,
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 200,
messages: [{ id: 'msg2' }],
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('resolved');
expect(state.allConversations[0].dataFetched).toBe(true);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
expect(state.allConversations[0].messages).toEqual([{ id: 'msg1' }]);
});
});
describe('#UPDATE_CONVERSATION_CONTACT', () => {
@@ -64,6 +64,7 @@ export default {
SET_CONTEXT_MENU_CHAT_ID: 'SET_CONTEXT_MENU_CHAT_ID',
SET_CHAT_DATA_FETCHED: 'SET_CHAT_DATA_FETCHED',
SET_CHAT_LIST_FILTERS: 'SET_CHAT_LIST_FILTERS',
UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS',
+3 -1
View File
@@ -27,7 +27,9 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
content_type: avatar_file.content_type
)
rescue Down::NotFound, Down::Error => e
rescue Down::NotFound
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
rescue Down::Error => e
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
ensure
update_avatar_sync_attributes(avatarable, avatar_url)
+2
View File
@@ -40,6 +40,7 @@ class Account < ApplicationRecord
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] },
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
@@ -88,6 +89,7 @@ class Account < ApplicationRecord
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :keep_pending_on_bot_failure
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
@@ -12,6 +12,10 @@ module AccountEmailRateLimitable
Redis::Alfred.get(email_count_cache_key).to_i
end
def email_transcript_enabled?
true
end
def within_email_rate_limit?
return true if emails_sent_today < email_rate_limit
@@ -19,10 +19,18 @@ module AutoAssignmentHandler
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
else
# Use legacy assignment system
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
# If conversation has a team, only consider team members for assignment
allowed_agent_ids = team_id.present? ? team_member_ids_with_capacity : inbox.member_ids_with_assignment_capacity
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: allowed_agent_ids).perform
end
end
def team_member_ids_with_capacity
return [] if team.blank? || team.allow_auto_assign.blank?
inbox.member_ids_with_assignment_capacity & team.members.ids
end
def should_run_auto_assignment?
return false unless inbox.enable_auto_assignment?
+16 -7
View File
@@ -130,11 +130,15 @@ class MailPresenter < SimpleDelegator
end
def sender_name
Mail::Address.new((@mail[:reply_to] || @mail[:from]).value).name
parse_mail_address((@mail[:reply_to] || @mail[:from]).value)&.name
end
def original_sender
from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
[
@mail[:reply_to]&.value,
@mail['X-Original-Sender']&.value,
@mail[:from]&.value
].filter_map { |email| parse_mail_address(email)&.address }.first
end
def headers_data
@@ -147,10 +151,6 @@ class MailPresenter < SimpleDelegator
headers.presence
end
def from_email_address(email)
Mail::Address.new(email).address
end
def email_forwarded_for
@mail['X-Forwarded-For'].try(:value)
end
@@ -175,11 +175,20 @@ class MailPresenter < SimpleDelegator
def notification_email_from_chatwoot?
# notification emails are send via mailer sender email address. so it should match
original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
configured_sender = Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
original_sender.to_s.casecmp?(configured_sender)
end
private
def parse_mail_address(email)
return if email.blank?
Mail::Address.new(email)
rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
nil
end
def auto_submitted?
@mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no'
end
+2
View File
@@ -75,6 +75,8 @@ class ActionService
end
def send_email_transcript(emails)
return unless @account.email_transcript_enabled?
emails = emails[0].gsub(/\s+/, '').split(',')
emails.each do |email|
@@ -3,6 +3,7 @@ class AutoAssignment::AssignmentService
def perform_bulk_assignment(limit: 100)
return 0 unless inbox.auto_assignment_v2_enabled?
return 0 unless inbox.enable_auto_assignment?
assigned_count = 0
@@ -18,7 +19,7 @@ class AutoAssignment::AssignmentService
def perform_for_conversation(conversation)
return false unless assignable?(conversation)
agent = find_available_agent
agent = find_available_agent(conversation)
return false unless agent
assign_conversation(conversation, agent)
@@ -32,7 +33,9 @@ class AutoAssignment::AssignmentService
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
scope = if assignment_config['conversation_priority'].to_s == 'longest_waiting'
# Apply conversation priority using assignment policy if available
policy = inbox.assignment_policy
scope = if policy&.longest_waiting?
scope.reorder(last_activity_at: :asc, created_at: :asc)
else
scope.reorder(created_at: :asc)
@@ -41,13 +44,26 @@ class AutoAssignment::AssignmentService
scope.limit(limit)
end
def find_available_agent
agents = filter_agents_by_rate_limit(inbox.available_agents)
def find_available_agent(conversation = nil)
agents = filter_agents_by_team(inbox.available_agents, conversation)
return nil if agents.nil?
agents = filter_agents_by_rate_limit(agents)
return nil if agents.empty?
round_robin_selector.select_agent(agents)
end
def filter_agents_by_team(agents, conversation)
return agents if conversation&.team_id.blank?
team = conversation.team
return nil if team.blank? || team.allow_auto_assign.blank?
team_member_ids = team.members.ids
agents.where(user_id: team_member_ids)
end
def filter_agents_by_rate_limit(agents)
agents.select do |agent_member|
rate_limiter = build_rate_limiter(agent_member.user)
@@ -81,10 +97,6 @@ class AutoAssignment::AssignmentService
def round_robin_selector
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
end
def assignment_config
@assignment_config ||= inbox.auto_assignment_config || {}
end
end
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
+2 -4
View File
@@ -8,8 +8,6 @@ class AutoAssignment::RateLimiter
end
def track_assignment(conversation)
return unless enabled?
assignment_key = build_assignment_key(conversation.id)
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
end
@@ -24,11 +22,11 @@ class AutoAssignment::RateLimiter
private
def enabled?
limit.present? && limit.positive?
config.present? && limit.positive?
end
def limit
config&.fair_distribution_limit&.to_i || Math
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY
end
def window
@@ -2,7 +2,6 @@ class MessageTemplates::HookExecutionService
pattr_initialize [:message!]
def perform
return if conversation.campaign.present?
return if conversation.last_incoming_message.blank?
return if message.auto_reply_email?
@@ -21,6 +20,7 @@ class MessageTemplates::HookExecutionService
end
def should_send_out_of_office_message?
return false if conversation.campaign.present?
# should not send if its a tweet message
return false if conversation.tweet?
# should not send for outbound messages
@@ -37,6 +37,7 @@ class MessageTemplates::HookExecutionService
end
def should_send_greeting?
return false if conversation.campaign.present?
# should not send if its a tweet message
return false if conversation.tweet?
@@ -49,6 +50,8 @@ class MessageTemplates::HookExecutionService
# TODO: we should be able to reduce this logic once we have a toggle for email collect messages
def should_send_email_collect?
return false if conversation.campaign.present?
!contact_has_email? && inbox.web_widget? && !email_collect_was_sent?
end
@@ -1,3 +1,5 @@
require 'faraday/multipart'
# Telegram Attachment APIs: ref: https://core.telegram.org/bots/api#inputfile
# Media attachments like photos, videos can be clubbed together and sent as a media group
@@ -111,17 +113,33 @@ class Telegram::SendAttachmentsService
def send_file(chat_id, file_path, reply_to_message_id)
File.open(file_path, 'rb') do |file|
HTTParty.post("#{channel.telegram_api_url}/sendDocument",
body: {
chat_id: chat_id,
**business_connection_body,
document: file,
reply_to_message_id: reply_to_message_id
},
multipart: true)
file_name = File.basename(file_path)
mime_type = Marcel::MimeType.for(name: file_name) || 'application/octet-stream'
payload = { chat_id: chat_id, document: Faraday::Multipart::FilePart.new(file, mime_type, file_name) }
payload[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
payload.merge!(business_connection_body)
response = multipart_post_connection.post("#{channel.telegram_api_url}/sendDocument", payload)
parse_faraday_response(response)
end
end
def multipart_post_connection
@multipart_post_connection ||= Faraday.new do |f|
f.request :multipart
f.options.timeout = 300
f.options.open_timeout = 60
end
end
def parse_faraday_response(response)
parsed = JSON.parse(response.body)
OpenStruct.new(success?: response.success?, parsed_response: parsed)
rescue JSON::ParserError
OpenStruct.new(success?: false, parsed_response: { 'ok' => false, 'error_code' => response.status, 'description' => response.reason_phrase })
end
def handle_response(response)
return true if response.success?
+13 -1
View File
@@ -47,8 +47,10 @@ class Twilio::DeliveryStatusService
@twilio_channel ||= if params[:MessagingServiceSid].present?
::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
elsif params[:AccountSid].present? && params[:From].present?
::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid], phone_number: params[:From])
::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: params[:From])
end
log_channel_not_found if @twilio_channel.blank?
@twilio_channel
end
def message
@@ -56,4 +58,14 @@ class Twilio::DeliveryStatusService
@message ||= twilio_channel.inbox.messages.find_by(source_id: params[:MessageSid])
end
def log_channel_not_found
Rails.logger.warn(
'[TWILIO] Delivery status channel lookup failed ' \
"account_sid=#{params[:AccountSid]} " \
"from=#{params[:From]} " \
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
"message_sid=#{params[:MessageSid]}"
)
end
end
@@ -26,12 +26,23 @@ class Twilio::IncomingMessageService
def twilio_channel
@twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present?
if params[:AccountSid].present? && params[:To].present?
@twilio_channel ||= ::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid],
phone_number: params[:To])
@twilio_channel ||= ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid],
phone_number: params[:To])
end
log_channel_not_found if @twilio_channel.blank?
@twilio_channel
end
def log_channel_not_found
Rails.logger.warn(
'[TWILIO] Incoming message channel lookup failed ' \
"account_sid=#{params[:AccountSid]} " \
"to=#{params[:To]} " \
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
"sms_sid=#{params[:SmsSid]}"
)
end
def inbox
@inbox ||= twilio_channel.inbox
end
@@ -28,19 +28,19 @@ class Whatsapp::IncomingMessageBaseService
# if the webhook event is a reaction or an ephermal message or an unsupported message.
return if unprocessable_message_type?(message_type)
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
# business manager account. While we have not found the core reason yet, the following line ensure that
# there are no duplicate messages created.
return if find_message_by_source_id(messages_data.first[:id]) || message_under_process?
# Multiple webhook events can be received for the same message due to
# misconfigurations in the Meta business manager account.
# We use an atomic Redis SET NX to prevent concurrent workers from both
# processing the same message simultaneously.
return if find_message_by_source_id(messages_data.first[:id])
return unless lock_message_source_id!
cache_message_source_id_in_redis
set_contact
return unless @contact
ActiveRecord::Base.transaction do
set_conversation
create_messages
clear_message_source_id_from_redis
end
end
@@ -69,20 +69,9 @@ module Whatsapp::IncomingMessageServiceHelpers
@message = Message.find_by(source_id: source_id)
end
def message_under_process?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
Redis::Alfred.get(key)
end
def lock_message_source_id!
return false if messages_data.blank?
def cache_message_source_id_in_redis
return if messages_data.blank?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.setex(key, true)
end
def clear_message_source_id_from_redis
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.delete(key)
Whatsapp::MessageDedupLock.new(messages_data.first[:id]).acquire!
end
end
@@ -0,0 +1,19 @@
# Atomic dedup lock for WhatsApp incoming messages.
#
# Meta can deliver the same webhook event multiple times. This lock uses
# Redis SET NX EX to ensure only one worker processes a given source_id.
class Whatsapp::MessageDedupLock
KEY_PREFIX = Redis::RedisKeys::MESSAGE_SOURCE_KEY
DEFAULT_TTL = 1.day.to_i
def initialize(source_id, ttl: DEFAULT_TTL)
@key = format(KEY_PREFIX, id: source_id)
@ttl = ttl
end
# Returns true when the lock is acquired (caller should proceed).
# Returns false when another worker already holds the lock.
def acquire!
::Redis::Alfred.set(@key, true, nx: true, ex: @ttl)
end
end
+4
View File
@@ -241,3 +241,7 @@
display_name: Required Conversation Attributes
enabled: false
premium: true
- name: advanced_assignment
display_name: Advanced Assignment
enabled: false
premium: true
+1 -2
View File
@@ -41,8 +41,7 @@ en:
invalid_email: 'Please enter a valid email address'
authentication_failed: 'Authentication failed. Please check your credentials and try again.'
messages:
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
reset_password_failure: Uh ho! We could not find any user with the specified email.
reset_password: Request for password reset is successful. A email with instructions will be sent to your email if it exists.
reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
saml_not_available: SAML authentication is not available in this installation.
@@ -1,10 +1,13 @@
module Captain::ChatResponseHelper
include Integrations::LlmInstrumentationConstants
private
def build_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
parsed = parse_json_response(response.content)
apply_credit_usage_metadata(parsed)
persist_message(parsed, 'assistant')
parsed
@@ -19,6 +22,26 @@ module Captain::ChatResponseHelper
{ 'content' => content }
end
def apply_credit_usage_metadata(parsed_response)
return unless captain_v1_assistant?
OpenTelemetry::Trace.current_span.set_attribute(
format(ATTR_LANGFUSE_METADATA, 'credit_used'),
credit_used_for_response?(parsed_response).to_s
)
rescue StandardError => e
Rails.logger.warn "#{self.class.name} Assistant: #{@assistant.id}, Failed to set credit usage metadata: #{e.message}"
end
def credit_used_for_response?(parsed_response)
response = parsed_response['response']
response.present? && response != 'conversation_handoff'
end
def captain_v1_assistant?
feature_name == 'assistant' && !@assistant.account.feature_enabled?('captain_integration_v2')
end
def persist_thinking_message(tool_call)
return if @copilot_thread.blank?
@@ -93,6 +93,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def send_out_of_office_message_if_applicable
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if @conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
end
@@ -1,6 +1,15 @@
class Messages::AudioTranscriptionJob < ApplicationJob
queue_as :low
discard_on Faraday::BadRequestError do |job, error|
log_context = {
attachment_id: job.arguments.first,
job_id: job.job_id,
status_code: error.response&.dig(:status)
}
Rails.logger.warn("Discarding audio transcription job due to bad request: #{log_context}")
end
retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3
def perform(attachment_id)
@@ -3,6 +3,12 @@ module Enterprise::Account
# this is a temporary method since current administrate doesn't support virtual attributes
def manually_managed_features; end
# Auto-sync advanced_assignment with assignment_v2 when features are bulk-updated via admin UI
def selected_feature_flags=(features)
super
sync_assignment_features
end
def mark_for_deletion(reason = 'manual_deletion')
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
@@ -31,4 +37,21 @@ module Enterprise::Account
def saml_enabled?
saml_settings&.saml_enabled? || false
end
private
def sync_assignment_features
if feature_enabled?('assignment_v2')
# Enable advanced_assignment for Business/Enterprise plans
send('feature_advanced_assignment=', true) if business_or_enterprise_plan?
else
# Disable advanced_assignment when assignment_v2 is disabled
send('feature_advanced_assignment=', false)
end
end
def business_or_enterprise_plan?
plan_name = custom_attributes['plan_name']
%w[Business Enterprise].include?(plan_name)
end
end
@@ -32,6 +32,13 @@ module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleL
save
end
def email_transcript_enabled?
default_plan = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')&.value&.first
return true if default_plan.blank?
plan_name.present? && plan_name != default_plan['name']
end
def email_rate_limit
account_limit || plan_email_limit || global_limit || default_limit
end
@@ -1,6 +1,9 @@
require 'agents'
require 'agents/instrumentation'
class Captain::Assistant::AgentRunnerService
include Integrations::LlmInstrumentationConstants
CONVERSATION_STATE_ATTRIBUTES = %i[
id display_id inbox_id contact_id status priority
label_list custom_attributes additional_attributes
@@ -22,7 +25,9 @@ class Captain::Assistant::AgentRunnerService
context = build_context(message_history)
message_to_process = extract_last_user_message(message_history)
runner = Agents::Runner.with_agents(*agents)
runner = add_usage_metadata_callback(runner)
runner = add_callbacks_to_runner(runner) if @callbacks.any?
install_instrumentation(runner)
result = runner.run(message_to_process, context: context, max_turns: 100)
process_agent_result(result)
@@ -50,6 +55,7 @@ class Captain::Assistant::AgentRunnerService
end
{
session_id: "#{@assistant.account_id}_#{@conversation&.display_id}",
conversation_history: conversation_history,
state: build_state
}
@@ -124,6 +130,31 @@ class Captain::Assistant::AgentRunnerService
[assistant_agent] + scenario_agents
end
def install_instrumentation(runner)
return unless ChatwootApp.otel_enabled?
Agents::Instrumentation.install(
runner,
tracer: OpentelemetryConfig.tracer,
trace_name: 'llm.captain_v2',
span_attributes: {
ATTR_LANGFUSE_TAGS => ['captain_v2'].to_json
},
attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) }
)
end
def dynamic_trace_attributes(context_wrapper)
state = context_wrapper&.context&.dig(:state) || {}
conversation = state[:conversation] || {}
{
ATTR_LANGFUSE_USER_ID => state[:account_id],
format(ATTR_LANGFUSE_METADATA, 'assistant_id') => state[:assistant_id],
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id]
}.compact.transform_values(&:to_s)
end
def add_callbacks_to_runner(runner)
runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
@@ -132,6 +163,36 @@ class Captain::Assistant::AgentRunnerService
runner
end
def add_usage_metadata_callback(runner)
return runner unless ChatwootApp.otel_enabled?
handoff_tool_name = Captain::Tools::HandoffTool.new(@assistant).name
runner.on_tool_complete do |tool_name, _tool_result, context_wrapper|
track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
end
runner.on_run_complete do |_agent_name, _result, context_wrapper|
write_credits_used_metadata(context_wrapper)
end
runner
end
def track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
return unless context_wrapper&.context
return unless tool_name.to_s == handoff_tool_name
context_wrapper.context[:captain_v2_handoff_tool_called] = true
end
def write_credits_used_metadata(context_wrapper)
root_span = context_wrapper&.context&.dig(:__otel_tracing, :root_span)
return unless root_span
credits_used = !context_wrapper.context[:captain_v2_handoff_tool_called]
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credits_used'), credits_used)
end
def add_agent_thinking_callback(runner)
runner.on_agent_thinking do |*args|
@callbacks[:on_agent_thinking].call(*args)
@@ -0,0 +1,49 @@
class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
MODEL = 'gpt-4.1-nano'.freeze
pattr_initialize [:account!]
def translate(query, target_language:)
return query if query_in_target_language?(query)
messages = [
{ role: 'system', content: system_prompt(target_language) },
{ role: 'user', content: query }
]
response = make_api_call(model: MODEL, messages: messages)
return query if response[:error]
response[:message].strip
rescue StandardError => e
Rails.logger.warn "TranslateQueryService failed: #{e.message}, falling back to original query"
query
end
private
def event_name
'translate_query'
end
def query_in_target_language?(query)
detector = CLD3::NNetLanguageIdentifier.new(0, 1000)
result = detector.find_language(query)
result.reliable? && result.language == account_language_code
rescue StandardError
false
end
def account_language_code
account.locale&.split('_')&.first
end
def system_prompt(target_language)
<<~SYSTEM_PROMPT_MESSAGE
You are a helpful assistant that translates queries from one language to another.
Translate the query to #{target_language}.
Return just the translated query, no other text.
SYSTEM_PROMPT_MESSAGE
end
end
@@ -9,7 +9,11 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
def execute(query:)
Rails.logger.info { "#{self.class.name}: #{query}" }
responses = assistant.responses.approved.search(query)
translated_query = Captain::Llm::TranslateQueryService
.new(account: assistant.account)
.translate(query, target_language: assistant.account.locale_english_name)
responses = assistant.responses.approved.search(translated_query)
return 'No FAQs found for the given query' if responses.empty?
@@ -18,7 +18,11 @@ class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
def execute(query:)
Rails.logger.info { "#{self.class.name}: #{query}" }
responses = search_responses(query)
translated_query = Captain::Llm::TranslateQueryService
.new(account: @account)
.translate(query, target_language: @account.locale_english_name)
responses = search_responses(translated_query)
return 'No FAQs found for the given query' if responses.empty?
responses.map { |response| format_response(response) }.join
@@ -14,12 +14,16 @@ module Enterprise::AutoAssignment::AssignmentService
end
# Extend agent finding to add capacity checks
def find_available_agent
agents = filter_agents_by_rate_limit(inbox.available_agents)
def find_available_agent(conversation = nil)
agents = filter_agents_by_team(inbox.available_agents, conversation)
return nil if agents.nil?
agents = filter_agents_by_rate_limit(agents)
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
return nil if agents.empty?
selector = policy&.balanced? ? balanced_selector : round_robin_selector
# Use balanced selector only if advanced_assignment feature is enabled
selector = policy&.balanced? && account.feature_enabled?('advanced_assignment') ? balanced_selector : round_robin_selector
selector.select_agent(agents)
end
@@ -31,7 +35,7 @@ module Enterprise::AutoAssignment::AssignmentService
end
def capacity_filtering_enabled?
account.feature_enabled?('assignment_v2') &&
account.feature_enabled?('advanced_assignment') &&
account.account_users.joins(:agent_capacity_policy).exists?
end
@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
].freeze
# Additional features available starting with the Business plan
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes].freeze
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
@@ -68,6 +68,10 @@ module Enterprise::MessageTemplates::HookExecutionService
end
def send_out_of_office_message_after_handoff
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
@@ -31,12 +31,20 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
end
def fetch_audio_file
blob = attachment.file.blob
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
FileUtils.mkdir_p(temp_dir)
temp_file_path = File.join(temp_dir, "#{attachment.file.blob.key}-#{attachment.file.filename}")
temp_file_name = "#{blob.key}-#{blob.filename}"
if blob.filename.extension_without_delimiter.blank?
extension = extension_from_content_type(blob.content_type)
temp_file_name = "#{temp_file_name}.#{extension}" if extension.present?
end
temp_file_path = File.join(temp_dir, temp_file_name)
File.open(temp_file_path, 'wb') do |file|
attachment.file.blob.open do |blob_file|
blob.open do |blob_file|
IO.copy_stream(blob_file, file)
end
end
@@ -49,13 +57,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
return transcribed_text if transcribed_text.present?
temp_file_path = fetch_audio_file
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
response = @client.audio.transcribe(
parameters: {
model: 'whisper-1',
model: WHISPER_MODEL,
file: file,
temperature: 0.4
}
@@ -63,10 +70,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
transcribed_text = response['text']
end
FileUtils.rm_f(temp_file_path)
update_transcription(transcribed_text)
transcribed_text
ensure
FileUtils.rm_f(temp_file_path) if temp_file_path.present?
end
def instrumentation_params(file_path)
@@ -90,4 +97,15 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
message.reindex
end
def extension_from_content_type(content_type)
subtype = content_type.to_s.downcase.split(';').first.to_s.split('/').last.to_s
return if subtype.blank?
{
'x-m4a' => 'm4a',
'x-wav' => 'wav',
'x-mp3' => 'mp3'
}.fetch(subtype, subtype)
end
end
@@ -42,6 +42,10 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
end
def send_out_of_office_message_if_applicable(conversation)
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
+25 -13
View File
@@ -1,5 +1,6 @@
module Captain::ToolInstrumentation
extend ActiveSupport::Concern
include Integrations::LlmInstrumentationConstants
private
@@ -10,15 +11,11 @@ module Captain::ToolInstrumentation
response = nil
executed = false
tracer.in_span(params[:span_name]) do |span|
span.set_attribute('langfuse.user.id', params[:account_id].to_s) if params[:account_id]
span.set_attribute('langfuse.tags', [params[:feature_name]].to_json)
span.set_attribute('langfuse.observation.input', params[:messages].to_json)
set_tool_session_attributes(span, params)
response = yield
executed = true
# Output just the message for cleaner Langfuse display
span.set_attribute('langfuse.observation.output', response[:message] || response.to_json)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
set_tool_session_error_attributes(span, response) if response.is_a?(Hash)
end
response
rescue StandardError => e
@@ -26,17 +23,32 @@ module Captain::ToolInstrumentation
executed ? response : yield
end
def set_tool_session_attributes(span, params)
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present?
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
end
def set_tool_session_error_attributes(span, response)
error = response[:error] || response['error']
return if error.blank?
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
end
def record_generation(chat, message, model)
return unless ChatwootApp.otel_enabled?
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
tracer.in_span("llm.#{event_name}.generation") do |span|
span.set_attribute('gen_ai.system', 'openai')
span.set_attribute('gen_ai.request.model', model)
span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens)
span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens)
span.set_attribute('langfuse.observation.input', format_chat_messages(chat))
span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content)
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model)
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens)
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, format_chat_messages(chat))
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content)
end
rescue StandardError => e
Rails.logger.warn "Failed to record generation: #{e.message}"
+3
View File
@@ -37,6 +37,7 @@ module Integrations::LlmInstrumentation
result = yield
executed = true
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
set_error_attributes(span, result) if result.is_a?(Hash)
result
end
rescue StandardError => e
@@ -50,9 +51,11 @@ module Integrations::LlmInstrumentation
return yield unless ChatwootApp.otel_enabled?
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
result = yield
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
set_error_attributes(span, result) if result.is_a?(Hash)
result
end
end
@@ -26,6 +26,7 @@ module Integrations::LlmInstrumentationConstants
ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s'
ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input'
ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output'
ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type'
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
end
@@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans
tool_name = tool_call.name.to_s
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
@pending_tool_spans ||= []
@@ -33,7 +33,7 @@ General guidelines:
- Reply in the customer's language
{% if has_search_tool %}
**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information.
**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information.
**Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation.
{% endif %}
@@ -102,7 +102,8 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
def send_message
post_message if message_content.present?
upload_files if message.attachments.any?
rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth,
rescue Slack::Web::Api::Errors::IsArchived, Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope,
Slack::Web::Api::Errors::InvalidAuth,
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
Rails.logger.error e
hook.prompt_reauthorization!
+2 -5
View File
@@ -2,13 +2,10 @@ module Linear::Mutations
def self.graphql_value(value)
case value
when String
# Strings must be enclosed in double quotes
"\"#{value.gsub("\n", '\\n')}\""
value.to_json
when Array
# Arrays need to be recursively converted
"[#{value.map { |v| graphql_value(v) }.join(', ')}]"
else
# Other types (numbers, booleans) can be directly converted to strings
value.to_s
end
end
@@ -47,7 +44,7 @@ module Linear::Mutations
<<~GRAPHQL
mutation {
attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}"#{user_params_str}) {
attachmentLinkURL(url: #{graphql_value(link)}, issueId: #{graphql_value(issue_id)}, title: #{graphql_value(title)}#{user_params_str}) {
success
attachment {
id
+1 -1
View File
@@ -48,7 +48,7 @@ module Linear::Queries
def self.search_issue(term)
<<~GRAPHQL
query {
searchIssues(term: "#{term}") {
searchIssues(term: #{Linear::Mutations.graphql_value(term)}) {
nodes {
id
title
+1 -1
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module LlmConstants
DEFAULT_MODEL = 'gpt-4.1-mini'
DEFAULT_MODEL = 'gpt-4.1'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
+10 -5
View File
@@ -36,16 +36,21 @@ class Webhooks::Trigger
case @webhook_type
when :agent_bot_webhook
conversation = message.conversation
return unless conversation&.pending?
conversation.open!
create_agent_bot_error_activity(conversation)
update_conversation_status(message)
when :api_inbox_webhook
update_message_status(error)
end
end
def update_conversation_status(message)
conversation = message.conversation
return unless conversation&.pending?
return if conversation&.account&.keep_pending_on_bot_failure
conversation.open!
create_agent_bot_error_activity(conversation)
end
def create_agent_bot_error_activity(conversation)
content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
+98 -57
View File
@@ -1,67 +1,108 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>The page you were looking for doesn't exist (404)</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Page not found</title>
<style>
body {
background-color: #EFEFEF;
color: #2E2F30;
text-align: center;
font-family: arial, sans-serif;
margin: 0;
}
div.dialog {
width: 95%;
max-width: 33em;
margin: 4em auto 0;
}
div.dialog > div {
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #BBB;
border-top: #B00100 solid 4px;
border-top-left-radius: 9px;
border-top-right-radius: 9px;
background-color: white;
padding: 7px 12% 0;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
h1 {
font-size: 100%;
color: #730E15;
line-height: 1.5em;
}
div.dialog > p {
margin: 0 0 1em;
padding: 1em;
background-color: #F7F7F7;
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #999;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top-color: #DADADA;
color: #666;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
background: rgba(39, 129, 246, 0.05);
color: rgb(28, 32, 36);
padding: 2rem 1.5rem;
-webkit-font-smoothing: antialiased;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 28rem;
}
.error-number {
font-size: 8rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.04em;
background: linear-gradient(180deg, rgb(39, 129, 246) 0%, rgb(155, 195, 252) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
text-align: center;
margin-bottom: 0.5rem;
}
.description {
font-size: 0.9375rem;
color: rgb(96, 100, 108);
line-height: 1.6;
text-align: center;
margin-bottom: 2.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: rgb(39, 129, 246);
color: #fff;
font-size: 0.9375rem;
font-weight: 500;
padding: 0.75rem 2rem;
border-radius: 0.625rem;
text-decoration: none;
transition: background 0.15s ease;
}
.btn:hover { background: rgb(16, 115, 233); }
.btn svg { width: 1.125rem; height: 1.125rem; }
.divider {
width: 3rem;
height: 2px;
background: rgb(224, 225, 230);
border-radius: 1px;
margin: 2.5rem 0;
}
.help {
font-size: 0.8125rem;
color: rgb(139, 141, 152);
text-align: center;
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
.error-number { background: linear-gradient(180deg, rgb(126, 182, 255) 0%, rgb(40, 89, 156) 100%); -webkit-background-clip: text; background-clip: text; }
.description { color: rgb(176, 180, 186); }
.divider { background: rgb(46, 49, 53); }
.help { color: rgb(105, 110, 119); }
}
@media (max-width: 480px) {
.error-number { font-size: 5rem; }
h1 { font-size: 1.25rem; }
}
</style>
</head>
<body>
<!-- This file lives in public/404.html -->
<div class="dialog">
<div>
<h1>The page you were looking for doesn't exist.</h1>
<p>You may have mistyped the address or the page may have moved.</p>
</div>
<p>If you are the application owner check the logs for more information.</p>
<div class="page">
<div class="error-number">404</div>
<h1>Page not found</h1>
<p class="description">The page you're looking for doesn't exist or has been moved.</p>
<a href="/" class="btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
Back to home
</a>
<div class="divider"></div>
<p class="help">If you think this is a mistake, please reach out to support.</p>
</div>
</body>
</html>
+103 -57
View File
@@ -1,67 +1,113 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>The change you wanted was rejected (422)</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Request rejected</title>
<style>
body {
background-color: #EFEFEF;
color: #2E2F30;
text-align: center;
font-family: arial, sans-serif;
margin: 0;
}
div.dialog {
width: 95%;
max-width: 33em;
margin: 4em auto 0;
}
div.dialog > div {
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #BBB;
border-top: #B00100 solid 4px;
border-top-left-radius: 9px;
border-top-right-radius: 9px;
background-color: white;
padding: 7px 12% 0;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
h1 {
font-size: 100%;
color: #730E15;
line-height: 1.5em;
}
div.dialog > p {
margin: 0 0 1em;
padding: 1em;
background-color: #F7F7F7;
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #999;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top-color: #DADADA;
color: #666;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
background: rgba(39, 129, 246, 0.05);
color: rgb(28, 32, 36);
padding: 2rem 1.5rem;
-webkit-font-smoothing: antialiased;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 28rem;
}
.error-number {
font-size: 8rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.04em;
background: linear-gradient(180deg, rgb(229, 70, 102) 0%, rgb(248, 191, 200) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
text-align: center;
margin-bottom: 0.5rem;
}
.description {
font-size: 0.9375rem;
color: rgb(96, 100, 108);
line-height: 1.6;
text-align: center;
margin-bottom: 2.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: rgb(39, 129, 246);
color: #fff;
font-size: 0.9375rem;
font-weight: 500;
padding: 0.75rem 2rem;
border-radius: 0.625rem;
text-decoration: none;
transition: background 0.15s ease;
}
.btn:hover { background: rgb(16, 115, 233); }
.btn svg { width: 1.125rem; height: 1.125rem; }
.divider {
width: 3rem;
height: 2px;
background: rgb(224, 225, 230);
border-radius: 1px;
margin: 2.5rem 0;
}
.help {
font-size: 0.8125rem;
color: rgb(139, 141, 152);
text-align: center;
line-height: 1.5;
}
.help a {
color: rgb(39, 129, 246);
text-decoration: none;
}
.help a:hover { text-decoration: underline; }
@media (prefers-color-scheme: dark) {
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
.error-number { background: linear-gradient(180deg, rgb(255, 148, 157) 0%, rgb(136, 52, 71) 100%); -webkit-background-clip: text; background-clip: text; }
.description { color: rgb(176, 180, 186); }
.divider { background: rgb(46, 49, 53); }
.help { color: rgb(105, 110, 119); }
}
@media (max-width: 480px) {
.error-number { font-size: 5rem; }
h1 { font-size: 1.25rem; }
}
</style>
</head>
<body>
<!-- This file lives in public/422.html -->
<div class="dialog">
<div>
<h1>The change you wanted was rejected.</h1>
<p>Maybe you tried to change something you didn't have access to.</p>
</div>
<p>If you are the application owner check the logs for more information.</p>
<div class="page">
<div class="error-number">422</div>
<h1>Request rejected</h1>
<p class="description">Your request couldn't be processed due to a security verification failure or invalid data.</p>
<a href="/" class="btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
Back to home
</a>
<div class="divider"></div>
<p class="help">If this keeps happening, try clearing your cookies and refreshing.</p>
</div>
</body>
</html>
+98 -56
View File
@@ -1,66 +1,108 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>We're sorry, but something went wrong (500)</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Something went wrong</title>
<style>
body {
background-color: #EFEFEF;
color: #2E2F30;
text-align: center;
font-family: arial, sans-serif;
margin: 0;
}
div.dialog {
width: 95%;
max-width: 33em;
margin: 4em auto 0;
}
div.dialog > div {
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #BBB;
border-top: #B00100 solid 4px;
border-top-left-radius: 9px;
border-top-right-radius: 9px;
background-color: white;
padding: 7px 12% 0;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
h1 {
font-size: 100%;
color: #730E15;
line-height: 1.5em;
}
div.dialog > p {
margin: 0 0 1em;
padding: 1em;
background-color: #F7F7F7;
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #999;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top-color: #DADADA;
color: #666;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
background: rgba(39, 129, 246, 0.05);
color: rgb(28, 32, 36);
padding: 2rem 1.5rem;
-webkit-font-smoothing: antialiased;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 28rem;
}
.error-number {
font-size: 8rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.04em;
background: linear-gradient(180deg, rgb(207, 74, 34) 0%, rgb(247, 164, 120) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
text-align: center;
margin-bottom: 0.5rem;
}
.description {
font-size: 0.9375rem;
color: rgb(96, 100, 108);
line-height: 1.6;
text-align: center;
margin-bottom: 2.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: rgb(39, 129, 246);
color: #fff;
font-size: 0.9375rem;
font-weight: 500;
padding: 0.75rem 2rem;
border-radius: 0.625rem;
text-decoration: none;
transition: background 0.15s ease;
}
.btn:hover { background: rgb(16, 115, 233); }
.btn svg { width: 1.125rem; height: 1.125rem; }
.divider {
width: 3rem;
height: 2px;
background: rgb(224, 225, 230);
border-radius: 1px;
margin: 2.5rem 0;
}
.help {
font-size: 0.8125rem;
color: rgb(139, 141, 152);
text-align: center;
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
.error-number { background: linear-gradient(180deg, rgb(255, 148, 114) 0%, rgb(148, 52, 27) 100%); -webkit-background-clip: text; background-clip: text; }
.description { color: rgb(176, 180, 186); }
.divider { background: rgb(46, 49, 53); }
.help { color: rgb(105, 110, 119); }
}
@media (max-width: 480px) {
.error-number { font-size: 5rem; }
h1 { font-size: 1.25rem; }
}
</style>
</head>
<body>
<!-- This file lives in public/500.html -->
<div class="dialog">
<div>
<h1>We're sorry, but something went wrong.</h1>
</div>
<p>If you are the application owner check the logs for more information.</p>
<div class="page">
<div class="error-number">500</div>
<h1>Something went wrong</h1>
<p class="description">An unexpected error occurred. Please refresh the page or try again shortly.</p>
<a href="/" class="btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
Back to home
</a>
<div class="divider"></div>
<p class="help">If the problem persists, please try again in a few minutes.</p>
</div>
</body>
</html>
@@ -330,6 +330,7 @@ RSpec.describe 'Conversations API', type: :request do
context 'when it is an authenticated user who has access to the inbox' do
before do
create(:inbox_member, user: agent, inbox: inbox)
create(:team_member, user: agent, team: team)
end
it 'creates a new conversation' do

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