Compare commits

...
Author SHA1 Message Date
Shivam Mishra 79b18e7009 Merge branch 'hotfix/4.11.2' 2026-03-09 21:19:50 +05:30
Shivam Mishra 432462f967 feat: harden filter service 2026-03-09 21:19:20 +05:30
Shivam Mishra a08125e283 Merge branch 'hotfix/4.11.1'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-02-20 20:02:18 +05:30
Shivam Mishra 15f25f019e chore: bump version 2026-02-20 20:02:09 +05:30
Shivam Mishra 280ca06e5b fix: url endpoint
fix: spec
2026-02-20 20:01:14 +05:30
Sojan Jose 2bd1d88d50 Merge branch 'release/4.11.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-02-17 15:40:39 -08:00
Sojan Jose e8152642f2 Bump version to 4.11.0 2026-02-17 15:35:20 -08:00
dae4f3ee13 fix: move llm call of captain outside transaction (#13559)
# Pull Request Template

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

The LLM call was wrapped in a transaction. This is an anti-pattern and
caused idle-connections which PG eventually terminated with
`PQconsumeInput() FATAL: terminating connection due to
idle-in-transaction timeout`

This resulted in activity messages being missing in some conversations
on captain handoff, failures queueing up for retry and captain
responding long after conversation was marked open/snoozed.

## 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.
locally and specs


## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-17 18:12:14 +05:30
e75e8a77f6 feat(shopify): Add mandatory compliance webhooks with HMAC verification (#13549)
Fixes
https://linear.app/chatwoot/issue/CW-6494/add-shopify-mandatory-compliance-webhooks-for-app-store-listing

Shopify requires all public apps to handle three GDPR compliance
webhooks before they can be listed on the App Store. Their automated
review checks for these endpoints and verifies that apps validate HMAC
signatures on incoming requests. We were failing both checks.

This PR adds a single webhook endpoint at `POST /webhooks/shopify` that
receives all three compliance events. When Shopify sends a webhook, it
signs the payload with our app's client secret and includes the
signature in the `X-Shopify-Hmac-SHA256` header. Our controller reads
the raw body, computes the expected HMAC-SHA256 digest, and rejects
mismatched requests with a 401.

Shopify identifies the event type through the `X-Shopify-Topic` header.
For `customers/data_request` and `customers/redact`, we simply
acknowledge with a 200—Chatwoot doesn't persist any Shopify customer
data. All order lookups happen as live API calls at query time. For
`shop/redact`, which Shopify sends after a merchant uninstalls the app,
we delete the integration hook for that shop domain and remove the
stored access token and configuration.


### How to test via Rails console
```
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
body = '{"shop_domain":"test.myshopify.com"}'
valid_hmac = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, body))
```

  #### Test 1: No HMAC → 401
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Topic' => 'customers/data_request' }
app.response.code  # => "401"
```
  ####  Test 2: Invalid HMAC → 401
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => 'invalid', 'X-Shopify-Topic' => 'customers/data_request' }
app.response.code  # => "401"
```
  ####  Test 3: Valid HMAC, customers/data_request → 200
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'customers/data_request' }
app.response.code  # => "200"
```

####  Test 4: Valid HMAC, customers/redact → 200
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'customers/redact' }
app.response.code  # => "200"
```

#### Test 5: Valid HMAC, shop/redact → 200 (deletes hook)
```  
# First check if a hook exists for this domain:
Integrations::Hook.where(app_id: 'shopify', reference_id: 'test.myshopify.com').count
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'shop/redact' }
app.response.code  # => "200"
```

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-02-17 16:52:13 +05:30
229f56d6e3 chore: Remove vue-multiselect and migrate to next components (#13506)
# Pull Request Template

## Description

This PR includes:
1. Removes multiselect usage from the Merge Contact modal (Conversation
sidebar) and replaces it with the existing component used on the Contact
Details page.
2. Replaces legacy form and multiselect elements in Add and Edit
automations flows with next components.**(Also check Macros)**
3. Replace multiselect with ComboBox in contact form country field.
4. Replace multiselect with TagInput in create/edit attribute form.
5. Replace multiselect with TagInput for agent selection in inbox
creation.
6. Replace multiselect with ComboBox in Facebook channel page selection

## Type of change

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

## How Has This Been Tested?

**Screenshots**

1. **Merge modal**
<img width="741" height="449" alt="image"
src="https://github.com/user-attachments/assets/a05a96ec-0692-4d94-9e27-d3e85fd143e4"
/>
<img width="741" height="449" alt="image"
src="https://github.com/user-attachments/assets/fc1dc977-689d-4440-869d-2124e4ca9083"
/>

2. **Automations**
<img width="849" height="1089" alt="image"
src="https://github.com/user-attachments/assets/b0155f06-ab21-4f90-a2c8-5bfbd97b08f7"
/>
<img width="813" height="879" alt="image"
src="https://github.com/user-attachments/assets/0921ac4a-88f5-49ac-a776-cc02941b479c"
/>
<img width="849" height="826" alt="image"
src="https://github.com/user-attachments/assets/44358dae-a076-4e10-b7ba-a4e40ccd817f"
/>

3. **Country field**
<img width="462" height="483" alt="image"
src="https://github.com/user-attachments/assets/d5db9aa1-b859-4327-9960-957d7091678f"
/>

4. **Add/Edit attribute form**
<img width="619" height="646" alt="image"
src="https://github.com/user-attachments/assets/6ab2ea94-73e5-40b8-ac29-399c0543fa7b"
/>
<img width="619" height="646" alt="image"
src="https://github.com/user-attachments/assets/b4c5bb0e-baa0-4ef7-a6a2-adb0f0203243"
/>
<img width="635" height="731" alt="image"
src="https://github.com/user-attachments/assets/74890c80-b213-4567-bf5f-4789dda39d2d"
/>

5. **Agent selection in inbox creation**
<img width="635" height="534" alt="image"
src="https://github.com/user-attachments/assets/0003bad1-1a75-4f20-b014-587e1c19a620"
/>
<img width="809" height="602" alt="image"
src="https://github.com/user-attachments/assets/5e7ab635-7340-420a-a191-e6cd49c02704"
/>

7. **Facebook channel page selection**
<img width="597" height="444" alt="image"
src="https://github.com/user-attachments/assets/f7ec8d84-0a7d-4bc6-92a1-a1365178e319"
/>
<img width="597" height="444" alt="image"
src="https://github.com/user-attachments/assets/d0596c4d-94c1-4544-8b50-e7103ff207a6"
/>
<img width="597" height="444" alt="image"
src="https://github.com/user-attachments/assets/be097921-011b-4dbe-b5f1-5d1306e25349"
/>



## Checklist:

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-02-17 16:40:12 +05:30
Aakash BakhleandGitHub 138840a23f fix: typo in metadata key in captain v2 (#13558)
# Pull Request Template

## Description

## Type of change

typo fix

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


## 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-17 15:40:50 +05:30
Sivin VargheseandGitHub c5f6844877 fix: Disable reply editor outside WhatsApp reply window (#13454) 2026-02-17 14:07:36 +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
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
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
Sojan JoseandGitHub 656ae41b24 fix(imap): handle IMAP parser/read errors without exception tracking (#13473)
When an IMAP server returns malformed or partial protocol responses,
Inboxes::FetchImapEmailsJob can raise Net::IMAP::ResponseParseError,
Net::IMAP::ResponseReadError, or Net::IMAP::ResponseTooLargeError.
Previously these errors fell through to the generic StandardError
handler and were captured repeatedly in Sentry.

This PR updates app/jobs/inboxes/fetch_imap_emails_job.rb to treat those
parser/read exceptions as handled IMAP failures by adding them to the
existing IMAP/network rescue block, so they are logged and retried on
the next scheduled run without exception tracking noise.

fixes:
https://chatwoot-p3.sentry.io/issues/7132037793/events/104fb9b4d80a4fb6ba3861c44c6c9b83/

How to reproduce:
- Use an IMAP server/inbox that intermittently returns malformed or
truncated protocol responses during search/fetch.
- Run Inboxes::FetchImapEmailsJob for that channel and observe the
raised parser/read exception.

How this was tested:
- bundle exec ruby -c app/jobs/inboxes/fetch_imap_emails_job.rb
- bundle exec rubocop app/jobs/inboxes/fetch_imap_emails_job.rb
2026-02-07 17:30:54 -08:00
Sojan JoseandGitHub f83415f299 fix(account-deletion): normalize deleted email suffix and handle collisions safely (#13472)
## Summary
This PR fixes account deletion failures by changing how orphaned user
emails are rewritten during `AccountDeletionService`.

Ref:
https://chatwoot-p3.sentry.io/issues/6715254765/events/e228a5d045ad47348d6c32448bc33b7a/

## Changes (develop -> this branch)
- Updated soft-delete email rewrite from:
  - `#{original_email}-deleted.com`
- To deterministic value:
  - `#{user.id}@chatwoot-deleted.invalid`
- Added reserved non-deliverable domain constant:
  - `@chatwoot-deleted.invalid`
- Replaced the "other accounts" check from `count.zero?` to `exists?`
(same behavior, cheaper query).
- Updated service spec expectation to match deterministic email value
and assert it differs from original email.

## Files changed
- `app/services/account_deletion_service.rb`
- `spec/services/account_deletion_service_spec.rb`

## How to verify
- Run: `bundle exec rspec
spec/services/account_deletion_service_spec.rb`
- Run: `bundle exec rubocop app/services/account_deletion_service.rb
spec/services/account_deletion_service_spec.rb`
2026-02-07 17:29:27 -08:00
Vishnu NarayananandGitHub 0a910c3763 fix: Add email rate limiting to automation rule actions (#13474) 2026-02-07 10:02:40 -08:00
PranavandGitHub 6a7cbcf5ba fix: Fixes reply-to in WhatsApp Cloud API (#13467)
This change https://github.com/chatwoot/chatwoot/pull/13371 broke the
functionality. When a user replies to a WhatsApp message, the reply
context wasn't being properly stored in Chatwoot due to #13371

WhatsApp sends reply messages with a `context` field containing the
original message ID:
```json
{
    "messages": [{
      "context": {
        "from": "phone_number",
        "id": "wamid.ORIGINAL_MESSAGE_ID"
      },
      "from": "phone_number",
      "id": "wamid.REPLY_MESSAGE_ID",
      "text": { "body": "This is a reply" }
    }]
  }
```
However, the in_reply_to_external_id was being overridden when building
the message because content_attributes was explicitly set to either {
external_echo: true } or {}, which discarded the reply-to information.
2026-02-06 14:01:01 -08:00
0e30e3c00a fix: add loading and silent retry to summary reports (#13455)
For large accounts, summary report queries can take several seconds to
complete, often times hitting the 15-second production request timeout.
The existing implementation silently swallows these failures and
provides no feedback during loading. Users see stale data with no
indication that a fetch is in progress, and if they interact with
filters while a request is in flight, they trigger race conditions that
can result in mismatched data being displayed.

This is a UX-level fix for what is fundamentally a performance problem.
While the underlying query performance is addressed separately, users
need proper feedback either way

## Approach

The PR adds three things: 

1. A loading overlay on the table, to provide feedback on loading state
2. Disabled filter inputs during loading so that the user does not
request new information that can cause race conditions in updating the
store
3. Silent retry before showing an error.

The retry exists because these queries often succeed on the second
attempt—likely due to database query caching. Rather than immediately
showing an error and forcing the user to manually retry, we do it
automatically. If the second attempt also fails, we show a toast so the
user knows something went wrong.

The store previously caught and discarded errors entirely. It now
rethrows them after resetting the loading flag, allowing components to
handle failures as they see fit.

### Previews

#### Double Retry and Error


https://github.com/user-attachments/assets/c189b173-8017-44b7-9493-417d65582c95

#### Loading State


https://github.com/user-attachments/assets/9f899c20-fbad-469b-93cc-f0d05d0853b0

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-02-06 19:53:46 +05:30
Sivin VargheseandGitHub 0d3b59fd9c feat: Refactor reports filters (#13443) 2026-02-06 18:22:30 +05:30
Shivam MishraandGitHub 04e747cc02 chore: temporarily disable ProcessStaleContactsJob (#13462)
We've been seeing orphan conversations (conversations whose contact has
been deleted) on high-frequency accounts. These orphans cause 500 errors
when the API attempts to render conversation data, since the jbuilder
partials expect a valid contact association.

The `ProcessStaleContactsJob` triggers `RemoveStaleContactsService`,
which uses `delete_all` to remove contacts. While the query only selects
contacts without conversations, `delete_all` bypasses ActiveRecord
callbacks and does not re-verify at deletion time. We suspect this
creates a window where a new conversation can be created for a contact
between query evaluation and the actual delete, leaving the conversation
orphaned.

**This is unverified — disabling the job is the experiment to confirm or
rule out this theory.**

This PR disables the job for a monitoring period. If orphan
conversations stop appearing, we'll have confirmation and can work on a
proper fix for the service. If they continue, we'll investigate other
deletion paths.

Stale contacts will accumulate while the job is disabled, but this is a
controlled tradeoff — they are inert records with no user-facing impact,
and can be cleaned up in bulk once we re-enable or fix the job.
2026-02-06 13:27:51 +05:30
053b7774dd fix: Render all account limit fields (#13435)
## Bug Explanation
- The Super Admin limits form renders inputs by iterating the keys of
`account.limits`.
- When `account.limits` was present, `AccountLimitsField#to_s` returned
only that hash (no defaults).
- On save, `SuperAdmin::AccountsController` compacts the limits hash,
removing blank keys.
- Result: if only one key (e.g., `agents`) was saved, the other keys
were missing from the hash and their fields disappeared on the next
render.

## Fix
- Always start from a defaults hash of all expected limit keys and merge
in any saved overrides.
- This keeps the UI stable and ensures all limit inputs remain visible
even when the stored hash is partial.
- Upgraded meta_request to `0.8.5` to stop a dev‑only `SystemStackError`
caused by JSON‑encoding ActiveRecord::Transaction in Rails 7.2. No
production behavior changes.

## Reproduction Steps
1. In Super Admin, edit an account and set only `agents` in the limits;
leave other limit fields blank and save.
2. Re-open the same account in Super Admin.
3. Observe that only `agents` is rendered and other limit fields are
missing.

## Testing
- Tested on UI

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-04 20:21:07 +05:30
Muhsin KelothandGitHub 8eaea7c72e feat: Add standalone outgoing messages count API endpoint (#13419)
This PR adds a new standalone `GET
/api/v2/accounts/:id/reports/outgoing_messages_count` endpoint that
returns outgoing message counts grouped by agent, team, inbox, or label.
2026-02-04 19:36:50 +05:30
Tanmay Deep SharmaandGitHub 7ade9061a8 feat: display total FAQ count in Related FAQs dialog (#13433)
## Description

Display the total count of generated FAQs in the Related FAQs dialog
title to give users immediate visibility into how many FAQs were
generated from a document.

## Type of change

Please delete options that are not relevant.

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

## Snapshots?

<img width="717" height="268" alt="Screenshot 2026-02-04 at 1 47 36 AM"
src="https://github.com/user-attachments/assets/c3e927ce-6d09-499d-8d02-8a44e0c353e2"
/>


## 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 UI-only change using existing store metadata; risk is limited to
incorrect/blank counts if `meta.totalCount` is missing or stale.
> 
> **Overview**
> Updates the `RelatedResponses` dialog to display the total related
response count in the title by reading
`captainResponses/getMeta.totalCount` (defaulting to 0) and appending it
as `(<count>)`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
7cd67c9991faceeff33d33c319e324b1c6cf73f4. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-04 11:27:51 +05:30
Sojan Jose 9eb3ee44a8 Revert "chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)"
This reverts commit ef6ba8aabd.
2026-02-03 21:09:42 -08:00
Sojan JoseandGitHub ef6ba8aabd chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)
Upgrade rails to 7.2.2 so that we can proceed with the rails 8 upgrade
afterwards
 
 # Changelog
- `.circleci/config.yml` — align CI DB setup with GitHub Actions
(`db:create` + `db:schema:load`) to avoid trigger-dependent prep steps.
- `.rubocop.yml` — add `rubocop-rspec_rails` and disable new cops that
don't match existing spec style.
- `AGENTS.md` — document that specs should run without `.env` (rename
temporarily when present).
- `Gemfile` — upgrade to Rails 7.2, switch Azure storage gem, pin
`commonmarker`, bump `sidekiq-cron`, add `rubocop-rspec_rails`, and
relax some gem pins.
- `Gemfile.lock` — dependency lockfile updates from the Rails 7.2 and
gem changes.
- `app/controllers/api/v1/accounts/integrations/linear_controller.rb` —
stringify params before passing to the Linear service to keep key types
stable.
- `app/controllers/super_admin/instance_statuses_controller.rb` — use
`MigrationContext` API for migration status in Rails 7.2.
- `app/models/installation_config.rb` — add commentary on YAML
serialization and future JSONB migration (no behavior change).
- `app/models/integrations/hook.rb` — ensure hook type is set on create
only and guard against missing app.
- `app/models/user.rb` — update enum syntax for Rails 7.2 deprecation,
serialize OTP backup codes with JSON, and use Ruby `alias`.
- `app/services/crm/leadsquared/setup_service.rb` — stringify hook
settings keys before merge to keep JSON shape consistent.
- `app/services/macros/execution_service.rb` — remove macro-specific
assignee activity workaround; rely on standard assignment handlers.
- `config/application.rb` — load Rails 7.2 defaults.
- `config/storage.yml` — update Azure Active Storage service name to
`AzureBlob`.
- `db/migrate/20230515051424_update_article_image_keys.rb` — use
credentials `secret_key_base` with fallback to legacy secrets.
- `docker/Dockerfile` — add `yaml-dev` and `pkgconf` packages for native
extensions (Ruby 3.4 / psych).
- `lib/seeders/reports/message_creator.rb` — add parentheses for clarity
in range calculation.
- `package.json` — pin Vite version and bump `vite-plugin-ruby`.
- `pnpm-lock.yaml` — lockfile changes from JS dependency updates.
- `spec/builders/v2/report_builder_spec.rb` — disable transactional
fixtures; truncate tables per example via Rails `truncate_tables` so
after_commit callbacks run with clean isolation; keep builder spec
metadata minimal.
- `spec/builders/v2/reports/label_summary_builder_spec.rb` — disable
transactional fixtures + truncate tables via Rails `truncate_tables`;
revert to real `resolved!`/`open!`/`resolved!` flow for multiple
resolution events; align date range to `Time.zone` to avoid offset gaps;
keep builder spec metadata minimal.
- `spec/controllers/api/v1/accounts/macros_controller_spec.rb` — assert
`assignee_id` instead of activity message to avoid transaction-timing
flakes.
- `spec/services/telegram/incoming_message_service_spec.rb` — reference
the contact tied to the created conversation instead of
`Contact.all.first` to avoid order-dependent failures when other specs
leave data behind.
-
`spec/mailers/administrator_notifications/shared/smtp_config_shared.rb`
— use `with_modified_env` instead of stubbing mailer internals.
- `spec/services/account/sign_up_email_validation_service_spec.rb` —
compare error `class.name` for parallel/reload-safe assertions.
2026-02-03 14:29:26 -08:00
Vishnu NarayananandGitHub c884cdefde feat: add per-account daily rate limit for outbound emails (#13411)
Introduce a daily cap on non-channel outbound emails to prevent abuse.

Fixes https://linear.app/chatwoot/issue/CW-6418/ses-incident-jan-28

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)

## Summary
- Adds a Redis-based daily counter to rate limit outbound emails per
account, preventing email abuse
- Covers continuity emails (WebWidget/API), conversation transcripts,
and agent notifications
  - Email channel replies are excluded (paid feature, not abusable)
- Adds account suspension check in `ConversationReplyMailer` to block
already-queued emails for suspended accounts

  ## Limit Resolution Hierarchy
1. Per-account override (`account.limits['emails']`) — SuperAdmin
configurable
2. Enterprise plan-based (`ACCOUNT_EMAILS_PLAN_LIMITS`
InstallationConfig)
3. Global default (`ACCOUNT_EMAILS_LIMIT` InstallationConfig, default:
100)
  4. Fallback (`ChatwootApp.max_limit` — effectively unlimited)

  ## Enforcement Points
  | Path | Where | Behavior |
  |------|-------|----------|
| WebWidget/API continuity |
`SendEmailNotificationService#should_send_email_notification?` |
Silently skipped |
| Widget transcript | `Widget::ConversationsController#transcript` |
Returns 429 |
| API transcript | `ConversationsController#transcript` | Returns 429 |
| Agent notifications | `Notification::EmailNotificationService#perform`
| Silently skipped |
  | Email channel replies | Not rate limited | Paid feature |
| Suspended accounts | `ConversationReplyMailer` | Blocked at mailer
level |
2026-02-03 02:06:51 +05:30
c77d935e38 fix: Subscribe app to WABA before overriding webhook callback URL (#13279)
#### Problem
Meta requires the app to be subscribed to the WABA before
`override_callback_uri` can be used. The current implementation tries to
use `override_callback_uri` directly, which fails with:

> Error 100: "Before override the current callback uri, your app must be
subscribed to receive messages for WhatsApp Business Account"

This causes embedded signup to fail silently, the inbox appears
connected but never receives messages.

  #### Solution

  Split `subscribe_waba_webhook` into two sequential API calls:

  ```ruby
  def subscribe_waba_webhook(waba_id, callback_url, verify_token)
    # Step 1: Subscribe app to WABA first (required before override)
    subscribe_app_to_waba(waba_id)

    # Step 2: Override callback URL for this specific WABA
    override_waba_callback(waba_id, callback_url, verify_token)
  end
```

#### References
  - Subscribe app to WABA's webhooks: https://www.postman.com/meta/whatsapp-business-platform/request/ju40fld/subscribe-app-to-waba-s-webhooks
  - Override Callback URL (Embedded Signup): https://www.postman.com/meta/whatsapp-business-platform/request/l6a09ow/override-callback-url

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-02-02 16:50:35 +05:30
Muhsin KelothandGitHub b686d14044 feat: Handle external echo messages from native apps (#13371)
When businesses use WhatsApp Business App (co-existence mode) or
Instagram App or TikTok alongside Chatwoot, messages sent from the
native apps were not synced properly back to Chatwoot. This left agents
with an incomplete conversation history and no visibility into responses
sent outside the dashboard. Additionally, if these echo messages did
arrive, they appeared as "Sent by: Bot" in the UI since they had no
sender, making it confusing for agents.

This PR subscribes to WhatsApp `smb_message_echoes` webhook events and
routes them through the existing service with an `outgoing_echo` flag,
mirroring how Instagram already handles echoes. On the Instagram side,
echo messages now also carry the `external_echo` content attribute and
`delivered` status.

On the frontend, messages with `externalEcho` are distinguished from bot
messages showing a "Native app" avatar and an advisory note encouraging
agents to reply from Chatwoot to maintain the service window.

<img width="1518" height="524" alt="CleanShot 2026-01-29 at 13 37 57@2x"
src="https://github.com/user-attachments/assets/5aa0b552-6382-441f-96aa-9a62ca716e4a"
/>


Fixes
https://linear.app/chatwoot/issue/CW-4204/display-messages-not-sent-from-chatwoot-in-case-of-outgoing-echo
Fixes
https://linear.app/chatwoot/issue/PLA-33/incoming-from-me-messages-from-whatsapp-business-app-are-not-falling
2026-02-02 15:52:53 +05:30
Shivam MishraandGitHub 133fb1bcf6 feat: add mark pending action to automation (#13378) 2026-02-02 11:59:51 +05:30
PranavandGitHub e9e6de5690 fix: Increase the parallelism config to fix flaky tests, revert bad commits (#13410)
The specs break only in Circle CI, we have to figure out the root cause
for the same. At the moment, I have increased the parallelism to fix
this.
2026-01-30 12:49:31 -08:00
PranavandGitHub 329b749702 Add API documentation for inbox, agent, and team summary report (#13409)
- Add API documentation for inbox, agent, and team summary report
endpoints
- These endpoints return conversation statistics grouped by
inbox/agent/team for a given date range

Endpoints documented:
GET /api/v2/accounts/{account_id}/summary_reports/inbox │ Conversation
stats grouped by inbox │
GET /api/v2/accounts/{account_id}/summary_reports/agent │ Conversation
stats grouped by agent │
GET /api/v2/accounts/{account_id}/summary_reports/team │ Conversation
stats grouped by team │

Query parameters (all endpoints):
- since - Start timestamp (Unix)
- until - End timestamp (Unix)
- business_hours - Calculate metrics using business hours only

Response fields:
- id - Inbox/Agent/Team ID
- conversations_count - Total conversations in date range
- resolved_conversations_count - Resolved conversations in date range
- avg_resolution_time - Average resolution time (seconds)
- avg_first_response_time - Average first response time (seconds)
- avg_reply_time - Average reply time (seconds)
2026-01-30 22:48:10 +04:00
PranavandGitHub d8c5dda36c chore: Update report documentation (#13408)
New API Documentation

GET
/api/v2/accounts/{account_id}/reports/first_response_time_distribution
  - Returns first response time distribution grouped by channel type
- Shows conversation counts in time buckets: 0-1h, 1-4h, 4-8h, 8-24h,
24h+
  - Parameters: since, until (Unix timestamps)

  GET /api/v2/accounts/{account_id}/reports/inbox_label_matrix
  - Returns a matrix of conversation counts for inbox-label combinations
  - Parameters: since, until, inbox_ids[], label_ids[]

  Fixes

  - Removed unused business_hours boolean parameter from
  /api/v2/accounts/{account_id}/summary_reports/channel
- Updated ReDoc script from unstable @next to stable @2.1.5 version to
fix empty swagger page
2026-01-30 22:33:03 +04:00
5ec77aca64 feat: Add first response time distribution report endpoint (#13400)
The index is already added in production.

Adds a new reporting API that returns conversation counts grouped by
channel type and first response time buckets (0-1h, 1-4h, 4-8h, 8-24h,
24h+).

- GET /api/v2/accounts/:id/reports/first_response_time_distribution
- Uses SQL aggregation to handle large datasets efficiently
- Adds composite index on reporting_events for query performance

Tested on production workload.
Request: GET
`/api/v2/accounts/1/reports/first_response_time_distribution?since=<since>&until=<until>`
Response payload:
```
{
    "Channel::WebWidget": {
      "0-1h": 120,
      "1-4h": 85,
      "4-8h": 32,
      "8-24h": 12,
      "24h+": 3
    },
    "Channel::Email": {
      "0-1h": 12,
      "1-4h": 28,
      "4-8h": 45,
      "8-24h": 35,
      "24h+": 10
    },
    "Channel::FacebookPage": {
      "0-1h": 50,
      "1-4h": 30,
      "4-8h": 15,
      "8-24h": 8,
      "24h+": 2
    }
  }
```

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-30 22:22:27 +04:00
Sivin VargheseandGitHub 85324c82fa fix: Formatting issue with reply preview content (#13399) 2026-01-30 16:35:32 +05:30
81307d5aea feat: search documentation tool for reply suggestions (#13340)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-01-30 16:18:33 +05:30
6f45af605c feat: Add inbox-label matrix report endpoint (#13394)
This PR added new API endpoint GET
/api/v2/accounts/:account_id/reports/inbox_label_matrix that returns
conversation counts grouped by inbox and label in a matrix format.
Supports optional filtering by date range, inbox_ids, and label_ids.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-29 13:32:59 -08:00
a32565d72b fix: velma connection limit (#13395)
## Description 
Make the $velma Redis connection pool size configurable via
`REDIS_VELMA_SIZE` environment variable (default: 5, matching current
behavior)
The $velma pool is used exclusively by Rack::Attack for rate limiting
and was the only Redis pool with a hardcoded size

## Fixes

Under high traffic, the hardcoded $velma pool (size: 5) causes
connection contention. Every HTTP request passes through Rack::Attack
middleware, which requires a $velma Redis connection. When
`WEB_CONCURRENCY=2` and `RAILS_MAX_THREADS=10` (20 concurrent threads),
the 4:1 thread-to-connection ratio causes threads to queue for up to 1
second (the pool timeout), resulting in intermittent request latency
spikes during traffic bursts.

The $alfred pool was already configurable via REDIS_ALFRED_SIZE — this
change brings $velma to parity.


## 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**
> Low risk: changes only Redis connection pool sizing for Rack::Attack;
misconfiguration could cause rate-limiting Redis contention or extra
connections but no data/auth logic changes.
> 
> **Overview**
> Makes the `velma` Redis connection pool (used by Rack::Attack)
configurable via a new `REDIS_VELMA_SIZE` env var, replacing the
previously hardcoded pool size.
> 
> Documents `REDIS_VELMA_SIZE` in `.env.example` alongside the existing
`REDIS_ALFRED_SIZE` setting.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
dcbc946f2e1d7356dc743178ca46cdf12cb25c78. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-01-29 20:53:41 +05:30
Muhsin KelothandGitHub 9238b4cc92 feat: Add the ability to configure WIDGET_TOKEN_EXPIRY (#13385)
This PR adds `WIDGET_TOKEN_EXPIRY` to the general config allowlist in the SuperAdmin app config page.
2026-01-29 09:57:01 +04:00
Muhsin KelothandGitHub acd0f1457e feat: Add TikTok social profile display support (#13384)
Fixes
https://linear.app/chatwoot/issue/PLA-77/store-tiktok-user-id-in-contact-attributes
Adds support for displaying TikTok profile links in the contact sidebar,
matching the behavior of other social channels (Instagram, Facebook,
Twitter, etc.).

<img width="400" height="600" alt="CleanShot 2026-01-28 at 15 48 14@2x"
src="https://github.com/user-attachments/assets/c861e6af-6a45-40ff-a4bf-7d46b8937691"
/>
2026-01-29 09:26:10 +04:00
77493c5d0f fix: captain assistant image comprehension (#13390)
# Pull Request Template

## Description

Fixes # (issue)

When we migrated to RubyLLM, images weren't being sent properly in
RubyLLM format to the model, so it did not understand images.

## Type of change

Please delete options that are not relevant.

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

specs + local testing

Current behaviour on staging:
<img width="772" height="1012" alt="image"
src="https://github.com/user-attachments/assets/7b7d360f-dea4-48af-b20b-ee4c98a38a85"
/>

local testing with fix:
<img width="792" height="1216" alt="image"
src="https://github.com/user-attachments/assets/5ef82452-015e-4bda-a68f-884d00acb014"
/>


## 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: Sojan Jose <sojan@pepalo.com>
2026-01-28 16:07:13 -08:00
PranavandGitHub 0d9c0b2ed2 fix: Force account_id in the query (#13388)
### What

Forces `account_id` to be applied consistently in queries and message creation paths.

### Why

Some queries were missing `account_id`, leading to cross-account scans and slow performance in large datasets.

### Changes

* Added `account_id` to the relevant query columns.
* Ensured messages are always created within the correct account scope.
* Updated `created_at` handling where required for consistency.

### Impact

* Prevents cross-account queries.
* Improves query performance.
* Reduces risk of incorrect data access across accounts.

### Notes

No functional behavior change for end users. This is a performance and safety fix.
2026-01-28 14:51:24 -08:00
2a69b37958 chore: Update theme colors and add new Inter variable fonts (#13347)
# Pull Request Template

## Description

This PR includes the following updates:

1. Updated the design system color tokens by introducing new tokens for
surfaces, overlays, buttons, labels, and cards, along with refinements
to existing shades.
2. Refreshed both light and dark themes with adjusted background,
border, and solid colors.
3. Replaced static Inter font files with the Inter variable font
(including italic), supporting weights from 100–900.
4. Added custom font weights (420, 440, 460, 520) along with custom
typography classes to enable more fine-grained and consistent typography
control.


## Type of change

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


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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-28 14:36:04 -08:00
Vishnu NarayananandGitHub 0ca98bc84f feat: add lightweight /health endpoint (#13386)
The existing /api health check endpoint creates a new Redis connection
on every request and checks both Redis and Postgres availability. During
peak traffic, this creates unnecessary load and can cause cascading
failures when either service is slow - instances get marked unhealthy,
traffic shifts to remaining instances, which then also fail health
checks.

The new /health endpoint:
  - Returns immediately with 200 {"status":"woot"}
  - Skips all middleware and authentication
  - No Redis or Postgres dependency
- Suitable for health checks that only need to verify the web server is
responding
2026-01-29 00:24:01 +05:30
Muhsin KelothandGitHub 6cd1b37981 feat: Tiktok API version configurable via Super Admin (#13381)
Extracted hardcoded TikTok API version (`v1.3`) into a configurable
`TIKTOK_API_VERSION` setting, consistent with how Instagram and WhatsApp
handle API versions.

Fixes
https://linear.app/chatwoot/issue/CW-6408/tiktok-api-version-configurable-via-super-admin
2026-01-28 19:46:48 +04:00
Muhsin KelothandGitHub 3b612e2b20 chore: Add unsupported message for Tiktok (#13380)
This PR adds the unsupported messages for tiktok.
Fixes
https://linear.app/chatwoot/issue/CW-6407/add-support-for-unsupported-message
2026-01-28 19:34:11 +04:00
d166ae73bc feat: add cron job to remove orphan conversations (#13335)
## Description

This PR includes cron job to delete the orphans

## 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]
> Introduces a scheduled cleanup for conversations missing `contact` or
`inbox`.
> 
> - Adds `Internal::RemoveOrphanConversationsService` to batch-delete
orphan conversations (scoped by optional `account`, within a
configurable `days` window) with progress logging
> - New `Internal::RemoveOrphanConversationsJob` that invokes the
service; scheduled via `config/schedule.yml` to run every 12 hours on
`housekeeping` queue
> - Refactors rake task `chatwoot:ops:cleanup_orphan_conversations` to
use the service and report `total_deleted` after confirmation
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
59a24715cc59f048d08db3f588cde6fa036f3166. 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-01-28 19:25:20 +05:30
Muhsin KelothandGitHub aaeea6c9bf feat: Display story replies with attachment and context label (#13356)
Fixes https://github.com/chatwoot/chatwoot/issues/13354
Fixes
https://linear.app/chatwoot/issue/CW-6394/story-responses-are-not-being-shown-in-the-ui
When someone replies to your Instagram story, agents in Chatwoot only
see the reply text with no story image and no indication that it was a
story reply. This makes it impossible to understand what the customer is
responding to the message looks like a random text with no context. For
example, if a customer replies "Love this!" to your story, the agent
just sees "Love this!" with no way to know which story triggered the
conversation. This PR fixes the issue by storing the story attachment
and adding a context label.

<img width="1408" height="2052" alt="CleanShot 2026-01-27 at 19 19
38@2x"
src="https://github.com/user-attachments/assets/341afea9-98e3-4e47-b2fa-ef77fe32851f"
/>
2026-01-28 16:47:04 +04:00
b870a48734 perf: limit the number of notifications per user to 300 (#13234)
## Linear issue


https://linear.app/chatwoot/issue/CW-6289/limit-the-number-of-notifications-per-user-to-300

## Description

Limits the number of notifications per user to 300 by introducing an
async trim job that runs after each notification creation. This prevents
unbounded notification growth that was causing DB CPU spikes.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- Added unit tests for TrimUserNotificationsJob

## 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]
> Implements a dedicated purge job to control notification volume and
scheduling.
> 
> - Introduces `Notification::RemoveOldNotificationJob` (queue:
`purgable`) to delete notifications older than 1 month and trim each
user to the 300 most recent (deterministic by `created_at DESC, id
DESC`)
> - Adds daily cron (`remove_old_notification_job` at 22:30 UTC, queue
`purgable`) in `config/schedule.yml`
> - Removes ad-hoc triggering of the purge from
`TriggerScheduledItemsJob`
> - Adds/updates specs covering enqueue queue, old-notification
deletion, per-user trimming, and combined behavior
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
9ea2b48e36df96cd15d4119d1dd7dcf5250695de. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-01-28 17:35:13 +05:30
68f0da7351 fix: Attachments download authentication issue in Tiktok (#13151)
Fixes
https://linear.app/chatwoot/issue/CW-6357/ensure-authentication-when-fetching-attachments
Update the attachment download method to include the access token in the
request headers, ensuring proper authentication when fetching
attachments.

https://business-api.tiktok.com/portal/docs?id=1832184455450626

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-28 11:50:14 +04:00
40c622ed95 fix: Tiktok nil conversation handling in ReadStatusService (#13152)
Fixes
https://linear.app/chatwoot/issue/CW-6358/handling-of-nil-conversation-in-the-readstatusservice
Improve handling of nil conversation in the `ReadStatusService` to
prevent potential errors. Ensure that the conversation is checked before
performing updates to message status. This change fixes the below error.



```
NoMethodError: undefined method 'conversations' for nil (NoMethodError)

    channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id).conversations.first
                                                                        ^^^^^^^^^^^^^^
    from app/services/tiktok/messaging_helpers.rb:29:in 'Tiktok::MessagingHelpers#find_conversation'
    from app/services/tiktok/read_status_service.rb:13:in 'Tiktok::ReadStatusService#conversation'
    from app/services/tiktok/read_status_service.rb:9:in 'Tiktok::ReadStatusService#perform'
    from app/jobs/webhooks/tiktok_events_job.rb:67:in 'Webhooks::TiktokEventsJob#im_mark_read_msg'
    from app/jobs/webhooks/tiktok_events_job.rb:31:in 'Webhooks::TiktokEventsJob#process_event'
    from app/jobs/webhooks/tiktok_events_job.rb:15:in 'block in Webhooks::TiktokEventsJob#perform'
    from app/jobs/mutex_application_job.rb:23:in 'MutexApplicationJob#with_lock'
    from app/jobs/webhooks/tiktok_events_job.rb:14:in 'Webhooks::TiktokEventsJob#perform'
    from activejob (7.1.5.2) lib/active_job/execution.rb:68:in 'block in ActiveJob::Execution#_perform_job'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:121:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from i18n (1.14.7) lib/i18n.rb:353:in 'I18n::Base#with_locale'
    from activejob (7.1.5.2) lib/active_job/translation.rb:9:in 'block (2 levels) in <module:Translation>'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'BasicObject#instance_exec'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from activesupport (7.1.5.2) lib/active_support/core_ext/time/zones.rb:65:in 'Time.use_zone'
    from activejob (7.1.5.2) lib/active_job/timezones.rb:9:in 'block (2 levels) in <module:Timezones>'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'BasicObject#instance_exec'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:141:in 'ActiveSupport::Callbacks#run_callbacks'
    from activejob (7.1.5.2) lib/active_job/execution.rb:67:in 'ActiveJob::Execution#_perform_job
```

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-28 10:41:19 +04:00
PranavandGitHub 7cddba2b08 feat: Add infinite scroll to contacts search page (#13376)
## Summary
- Add `has_more` to contacts search API response to enable infinite
scroll without expensive count queries
- Set `count` to the number of items in the current page instead of
total count
- Implement "Load more" button for contacts search results
- Keep existing contacts visible while loading additional pages

## Changes

### Backend
- Add `fetch_contacts_with_has_more` method that fetches N+1 records to
determine if more pages exist
- Return `has_more` in search endpoint meta response
- Set `count` to current page size instead of total count

### Frontend
- Add `APPEND_CONTACTS` mutation for appending contacts without clearing
existing ones
- Update search action to support `append` parameter
- Add `ContactsLoadMore` component with loading state
- Update `ContactsListLayout` to support infinite scroll mode
- Update `ContactsIndex` to use infinite scroll for search view
2026-01-27 18:55:19 -08:00
Sojan Jose 1345f67966 Merge branch 'release/4.10.1'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-01-20 08:44:14 -08:00
Sojan Jose 0f914fa2ab Merge branch 'release/4.10.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-01-15 22:20:22 -08:00
Sojan Jose 59663dd558 Merge branch 'hotfix/4.9.2'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2026-01-12 09:15:17 -08:00
397 changed files with 12442 additions and 4776 deletions
+7 -7
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 16
parallelism: 18
steps:
- checkout
- node/install:
@@ -350,12 +350,12 @@ jobs:
destination: coverage
build:
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
workflows:
version: 2
+1
View File
@@ -276,3 +276,4 @@ AZURE_APP_SECRET=
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
+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'
+19 -17
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,11 +540,11 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.0.4)
marcel (1.1.0)
maxminddb (0.1.22)
meta_request (0.8.3)
meta_request (0.8.5)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
railties (>= 3.0.0, < 9)
method_source (1.1.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
@@ -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
+1 -1
View File
@@ -1 +1 @@
4.10.1
4.11.2
@@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
create_story_reply_attachment(story_reply_attributes['url'])
end
def create_story_reply_attachment(story_url)
return if story_url.blank?
attachment = @message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
attachment.save!
begin
attach_file(attachment, story_url)
rescue Down::Error, StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
end
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
end
def build_conversation
@@ -139,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
@@ -147,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
}
}
params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
@@ -0,0 +1,68 @@
class V2::Reports::FirstResponseTimeDistributionBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
build_distribution
end
private
def build_distribution
results = fetch_aggregated_counts
map_to_channel_types(results)
end
def fetch_aggregated_counts
ReportingEvent
.where(account_id: account.id, name: 'first_response')
.where(range_condition)
.group(:inbox_id)
.select(
:inbox_id,
bucket_case_statements
)
end
def bucket_case_statements
<<~SQL.squish
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
SQL
end
def range_condition
range.present? ? { created_at: range } : {}
end
def inbox_channel_types
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
end
def map_to_channel_types(results)
results.each_with_object({}) do |row, hash|
channel_type = inbox_channel_types[row.inbox_id]
next unless channel_type
hash[channel_type] ||= empty_buckets
hash[channel_type]['0-1h'] += row.bucket_0_1h
hash[channel_type]['1-4h'] += row.bucket_1_4h
hash[channel_type]['4-8h'] += row.bucket_4_8h
hash[channel_type]['8-24h'] += row.bucket_8_24h
hash[channel_type]['24h+'] += row.bucket_24h_plus
end
end
def empty_buckets
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
end
end
@@ -0,0 +1,65 @@
class V2::Reports::InboxLabelMatrixBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
{
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
matrix: build_matrix
}
end
private
def filtered_inboxes
@filtered_inboxes ||= begin
inboxes = account.inboxes
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
inboxes.order(:name).to_a
end
end
def filtered_labels
@filtered_labels ||= begin
labels = account.labels
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
labels.order(:title).to_a
end
end
def conversation_filter
filter = { account_id: account.id }
filter[:created_at] = range if range.present?
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
filter
end
def fetch_grouped_counts
label_names = filtered_labels.map(&:title)
return {} if label_names.empty?
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
.where(tags: { name: label_names })
.group('conversations.inbox_id', 'tags.name')
.count
end
def build_matrix
counts = fetch_grouped_counts
filtered_inboxes.map do |inbox|
filtered_labels.map do |label|
counts[[inbox.id, label.title]] || 0
end
end
end
end
@@ -0,0 +1,79 @@
class V2::Reports::OutgoingMessagesCountBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
send("build_by_#{params[:group_by]}")
end
private
def base_messages
account.messages.outgoing.unscope(:order).where(created_at: range)
end
def build_by_agent
counts = base_messages
.where(sender_type: 'User')
.where.not(sender_id: nil)
.group(:sender_id)
.count
user_names = account.users.where(id: counts.keys).index_by(&:id)
counts.map do |user_id, count|
user = user_names[user_id]
{ id: user_id, name: user&.name, outgoing_messages_count: count }
end
end
def build_by_team
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.where.not(conversations: { team_id: nil })
.group('conversations.team_id')
.count
team_names = account.teams.where(id: counts.keys).index_by(&:id)
counts.map do |team_id, count|
team = team_names[team_id]
{ id: team_id, name: team&.name, outgoing_messages_count: count }
end
end
def build_by_inbox
counts = base_messages
.group(:inbox_id)
.count
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
counts.map do |inbox_id, count|
inbox = inbox_names[inbox_id]
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
end
end
def build_by_label
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
.group('tags.name')
.count
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
counts.map do |label_name, count|
label = label_ids[label_name]
{ id: label&.id, name: label_name, outgoing_messages_count: count }
end
end
end
@@ -28,8 +28,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
@contacts = fetch_contacts_with_has_more(contacts)
end
def import
@@ -142,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
.per(RESULTS_PER_PAGE)
end
def fetch_contacts_with_has_more(contacts)
includes_hash = { avatar_attachment: [:blob] }
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
# Calculate offset manually to fetch one extra record for has_more check
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
results = filtrate(contacts)
.includes(includes_hash)
.offset(offset)
.limit(RESULTS_PER_PAGE + 1)
.to_a
@has_more = results.size > RESULTS_PER_PAGE
results = results.first(RESULTS_PER_PAGE) if @has_more
@contacts_count = results.size
results
end
def build_contact_inbox
return if params[:inbox_id].blank?
@@ -70,8 +70,11 @@ 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
@conversation.account.increment_email_sent_count
head :ok
end
@@ -35,12 +35,11 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
end
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
end
@@ -77,6 +76,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
private
def send_transcript_email
return if conversation.contact&.email.blank?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
conversation.account.increment_email_sent_count
end
def trigger_typing_event(event)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
end
@@ -62,6 +62,31 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: bot_metrics
end
def inbox_label_matrix
builder = V2::Reports::InboxLabelMatrixBuilder.new(
account: Current.account,
params: inbox_label_matrix_params
)
render json: builder.build
end
def first_response_time_distribution
builder = V2::Reports::FirstResponseTimeDistributionBuilder.new(
account: Current.account,
params: first_response_time_distribution_params
)
render json: builder.build
end
OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze
def outgoing_messages_count
return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by])
builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
render json: builder.build
end
private
def generate_csv(filename, template)
@@ -139,4 +164,28 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
def inbox_label_matrix_params
{
since: params[:since],
until: params[:until],
inbox_ids: params[:inbox_ids],
label_ids: params[:label_ids]
}
end
def first_response_time_distribution_params
{
since: params[:since],
until: params[:until]
}
end
def outgoing_messages_count_params
{
group_by: params[:group_by],
since: params[:since],
until: params[:until]
}
end
end
@@ -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
+7
View File
@@ -0,0 +1,7 @@
# Inherits from ActionController::Base to skip all middleware,
# authentication, and callbacks. Used for health checks
class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
def show
render json: { status: 'woot' }
end
end
@@ -42,11 +42,11 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
@@ -55,7 +55,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = mapping.fetch(
@config,
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
end
@@ -0,0 +1,35 @@
class Webhooks::ShopifyController < ActionController::API
before_action :verify_hmac!
def events
case request.headers['X-Shopify-Topic']
when 'shop/redact'
handle_shop_redact
end
head :ok
end
private
def verify_hmac!
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
return head :unauthorized if secret.blank?
data = request.body.read
request.body.rewind
hmac_header = request.headers['X-Shopify-Hmac-SHA256']
return head :unauthorized if hmac_header.blank?
computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
end
def handle_shop_redact
shop_domain = params[:shop_domain]
return if shop_domain.blank?
Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
end
end
+9 -5
View File
@@ -47,11 +47,15 @@ module Filters::FilterHelper
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
if data_type == 'text_case_insensitive'
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
ActiveRecord::Base.sanitize_sql_array(
["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}",
query_hash[:attribute_key]]
)
else
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
"#{filter_operator_value} #{query_hash[:query_operator]} "
ActiveRecord::Base.sanitize_sql_array(
["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ",
query_hash[:attribute_key]]
)
end
end
@@ -70,7 +74,7 @@ module Filters::FilterHelper
def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_case_insensitive_filter(query_hash, filter_operator_value)
+1 -1
View File
@@ -131,7 +131,7 @@ export default {
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex flex-col w-full h-screen min-h-0"
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
@@ -107,21 +107,39 @@
--violet-11: 101 85 183;
--violet-12: 47 38 95;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--background-color: 247 247 247;
--surface-1: 254 254 254;
--surface-2: 255 255 255;
--surface-active: 255 255 255;
--background-input-box: 0, 0, 0, 0.03;
--text-blue: 1 22 44;
--text-purple: 2 4 49;
--text-amber: 37 24 1;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-strong: 226 227 231;
--border-weak: 234 234 234;
--border-blue-strong: 18 61 117;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-amber: 255 228 181;
--solid-blue: 218 236 255;
--solid-blue-2: 251 253 255;
--solid-iris: 230 231 255;
--solid-purple: 230 231 255;
--solid-red: 254 200 201;
--solid-amber-button: 255 221 141;
--card-color: 255 255 255;
--overlay: 0, 0, 0, 0.12;
--overlay-avatar: 255, 255, 255, 0.67;
--button-color: 255 255 255;
--button-hover-color: 255, 255, 255, 0.2;
--label-background: 247 247 247;
--label-border: 0, 0, 0, 0.04;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-1: 215, 215, 215, 0.22;
--alpha-2: 196, 197, 198, 0.22;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
@@ -235,25 +253,43 @@
--violet-11: 169 153 236;
--violet-12: 226 221 254;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--background-color: 28 29 32;
--surface-1: 20 21 23;
--surface-2: 22 23 26;
--surface-active: 53 57 66;
--background-input-box: 255, 255, 255, 0.02;
--text-blue: 213 234 255;
--text-purple: 232 233 254;
--text-amber: 255 247 234;
--border-strong: 46 45 50;
--border-weak: 31 31 37;
--border-blue-strong: 201 226 255;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-amber: 56 50 41;
--solid-blue: 15 57 102;
--solid-blue-2: 26 29 35;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--solid-purple: 51 51 107;
--solid-red: 90 33 34;
--solid-amber-button: 255 221 141;
--card-color: 28 30 34;
--overlay: 0, 0, 0, 0.4;
--overlay-avatar: 0, 0, 0, 0.05;
--button-color: 42 43 51;
--button-hover-color: 0, 0, 0, 0.15;
--label-background: 36 38 45;
--label-border: 255, 255, 255, 0.03;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--alpha-1: 35, 36, 42, 0.8;
--alpha-2: 147, 153, 176, 0.12;
--alpha-3: 33, 34, 38, 0.95;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--border-container: 255, 255, 255, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}
@@ -7,7 +7,7 @@
@apply bg-n-slate-3;
&::after {
@apply text-n-blue-text;
@apply text-n-blue-11;
}
}
}
@@ -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
@@ -119,7 +119,7 @@ onMounted(() => {
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
class="[&>button]:!text-n-blue-11 [&>div]:min-w-64"
@add="onClickAddTag"
/>
</div>
@@ -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>
@@ -20,7 +20,7 @@ const handleButtonClick = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -174,7 +174,7 @@ const handleSubmit = async () => {
color="slate"
type="button"
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -251,7 +251,7 @@ watch(
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -21,7 +21,7 @@ const updateCurrentPage = page => {
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<CompanyHeader
@@ -73,7 +73,7 @@ const closeMobileSidebar = () => {
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
@@ -73,7 +73,7 @@ defineExpose({ dialogRef });
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample.csv"
class="text-n-blue-text"
class="text-n-blue-11"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
@@ -68,8 +68,7 @@ const hasActiveSegments = computed(
);
const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = async () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
const openCreateNewContactDialog = () => {
createNewContactDialogRef.value?.dialogRef.open();
};
const openContactImportDialog = () =>
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import ContactsLoadMore from 'dashboard/components-next/Contacts/ContactsLoadMore.vue';
const props = defineProps({
searchValue: { type: String, default: '' },
@@ -19,6 +20,9 @@ const props = defineProps({
segmentsId: { type: [String, Number], default: 0 },
hasAppliedFilters: { type: Boolean, default: false },
isFetchingList: { type: Boolean, default: false },
useInfiniteScroll: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false },
isLoadingMore: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -27,6 +31,7 @@ const emit = defineEmits([
'search',
'applyFilter',
'clearFilters',
'loadMore',
]);
const route = useRoute();
@@ -61,11 +66,19 @@ const updateCurrentPage = page => {
const openFilter = () => {
contactListHeaderWrapper.value?.onToggleFilters();
};
const showLoadMore = computed(() => {
return props.useInfiniteScroll && props.hasMore;
});
const showPagination = computed(() => {
return !props.useInfiniteScroll && props.showPaginationFooter;
});
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
@@ -94,9 +107,14 @@ const openFilter = () => {
@open-filter="openFilter"
/>
<slot name="default" />
<ContactsLoadMore
v-if="showLoadMore"
:is-loading="isLoadingMore"
@load-more="emit('loadMore')"
/>
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
@@ -0,0 +1,28 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['loadMore']);
const { t } = useI18n();
</script>
<template>
<div class="flex justify-center py-4">
<Button
:label="t('CONTACTS_LAYOUT.LOAD_MORE')"
:is-loading="isLoading"
variant="faded"
color="slate"
size="sm"
@click="emit('loadMore')"
/>
</div>
</template>
@@ -130,7 +130,7 @@ const onMergeContacts = async () => {
variant="faded"
color="slate"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
@@ -128,7 +128,7 @@ const handleInputUpdate = async () => {
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
'truncate hover:text-n-brand text-n-blue-text':
'truncate hover:text-n-brand text-n-blue-11':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(!isEditingView)"
@@ -37,7 +37,7 @@ defineProps({
<div
class="flex flex-col items-center justify-end w-full h-full pb-20"
:class="{
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-surface-1 from-25% to-transparent':
showBackdrop,
}"
>
@@ -48,14 +48,12 @@ defineProps({
}"
>
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
>
<h2 class="text-3xl font-medium text-center text-n-slate-12">
{{ title }}
</h2>
<p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
class="max-w-xl text-base text-center text-n-slate-11 tracking-[0.3px]"
>
{{ subtitle }}
</p>
@@ -126,7 +126,7 @@ const handleClick = id => {
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
@@ -83,7 +83,7 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
@click="handleClick(slug)"
>
{{ categoryTitleWithIcon }}
@@ -58,7 +58,7 @@ const togglePortalSwitcher = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div
@@ -60,7 +60,7 @@ const handleAction = ({ action, value }) => {
</span>
<span
v-if="isDefault"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-11 px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span>
@@ -246,7 +246,7 @@ defineExpose({ state, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -100,7 +100,7 @@ const formattedMessage = computed(() => {
const notificationDetails = computed(() => {
const type = props.inboxItem?.notificationType?.toUpperCase() || '';
const [icon = '', color = 'text-n-blue-text'] =
const [icon = '', color = 'text-n-blue-11'] =
NOTIFICATION_TYPES_MAPPING[type] || [];
return { text: type ? t(`INBOX.TYPES_NEXT.${type}`) : '', icon, color };
});
@@ -181,11 +181,11 @@ onBeforeMount(contextMenuActions.close);
: 'i-lucide-alarm-clock-off'
"
class="flex-shrink-0 size-4"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
/>
<span
class="text-xs font-medium truncate"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
>
{{ snoozedText }}
</span>
@@ -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,
@@ -95,6 +95,7 @@ const inputClass = computed(() => {
:show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)"
@@ -127,6 +128,7 @@ const inputClass = computed(() => {
:show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@@ -103,11 +103,11 @@ const STYLE_CONFIG = {
solid:
'bg-n-brand text-white hover:enabled:brightness-110 focus-visible:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-brand',
'bg-n-brand/10 text-n-blue-11 hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-11 outline-n-brand',
ghost:
'text-n-blue-text hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:enabled:underline focus-visible:underline outline-transparent',
'text-n-blue-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-11 hover:enabled:underline focus-visible:underline outline-transparent',
},
ruby: {
solid:
@@ -133,7 +133,7 @@ const STYLE_CONFIG = {
},
slate: {
solid:
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
'bg-n-button-color dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded:
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent',
outline:
@@ -115,7 +115,7 @@ const handleCreateAssistant = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto">
<div
@@ -141,7 +141,7 @@ const onClickCancel = () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 !text-n-blue-11 hover:bg-n-alpha-3"
@click="onClickCancel"
/>
<Button
@@ -169,7 +169,7 @@ watch(
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -254,7 +254,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -220,7 +220,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -18,7 +18,9 @@ const dialogRef = ref(null);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const meta = useMapGetter('captainResponses/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.totalCount || 0);
const handleClose = () => {
emit('close');
@@ -37,7 +39,7 @@ defineExpose({ dialogRef });
<Dialog
ref="dialogRef"
type="edit"
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@@ -100,7 +100,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -116,7 +116,7 @@ watch(
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -34,7 +34,7 @@ const handleImgClick = () => {
<template>
<div
data-testid="changelog-card"
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-background text-n-slate-12"
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-card text-n-slate-12"
:class="{
'animate-fade-out pointer-events-none': isDismissing,
'hover:shadow': isActive,
@@ -56,8 +56,13 @@ const selectedLabel = computed(() => {
});
const selectOption = option => {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
if (selectedValue.value === option.value) {
selectedValue.value = '';
emit('update:modelValue', '');
} else {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
}
open.value = false;
search.value = '';
};
@@ -53,6 +53,11 @@ const props = defineProps({
default: 'lg',
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
},
position: {
type: String,
default: 'center',
validator: value => ['center', 'top'].includes(value),
},
});
const emit = defineEmits(['confirm', 'close']);
@@ -61,6 +66,7 @@ const { t } = useI18n();
const dialogRef = ref(null);
const dialogContentRef = ref(null);
const isOpen = ref(false);
const maxWidthClass = computed(() => {
const classesMap = {
@@ -75,13 +81,19 @@ const maxWidthClass = computed(() => {
return classesMap[props.width] ?? 'max-w-md';
});
const positionClass = computed(() =>
props.position === 'top' ? 'dialog-position-top' : ''
);
const open = () => {
isOpen.value = true;
dialogRef.value?.showModal();
};
const close = () => {
emit('close');
dialogRef.value?.close();
isOpen.value = false;
};
const confirm = () => {
@@ -98,6 +110,7 @@ defineExpose({ open, close });
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="[
maxWidthClass,
positionClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]"
@close="close"
@@ -105,7 +118,7 @@ defineExpose({ open, close });
<OnClickOutside @trigger="close">
<form
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@submit.prevent="confirm"
@click.stop
>
@@ -119,7 +132,7 @@ defineExpose({ open, close });
</p>
</slot>
</div>
<slot />
<slot v-if="isOpen" />
<!-- Dialog content will be injected here -->
<slot name="footer">
<div
@@ -156,4 +169,9 @@ defineExpose({ open, close });
dialog::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px];
}
.dialog-position-top {
margin-top: clamp(2rem, 5vh, 5rem);
margin-bottom: auto;
}
</style>
@@ -4,6 +4,10 @@ defineProps({
type: String,
default: '',
},
height: {
type: String,
default: 'max-h-96',
},
});
</script>
@@ -15,7 +19,10 @@ defineProps({
>
{{ title }}
</div>
<ul class="gap-2 grid reset-base list-none px-2 max-h-96 overflow-y-auto">
<ul
class="gap-2 grid reset-base list-none px-2 overflow-y-auto"
:class="height"
>
<slot />
</ul>
</div>
@@ -50,12 +50,12 @@ const currentFilter = computed(() =>
);
const getOperator = (filter, selectedOperator) => {
const operatorFromOptions = filter.filterOperators.find(
const operatorFromOptions = filter?.filterOperators?.find(
operator => operator.value === selectedOperator
);
if (!operatorFromOptions) {
return filter.filterOperators[0];
return filter?.filterOperators?.[0];
}
return operatorFromOptions;
@@ -77,12 +77,12 @@ const queryOperatorOptions = computed(() => {
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
value: 'and',
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-11' }),
},
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
value: 'or',
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-11' }),
},
];
});
@@ -138,7 +138,11 @@ const validate = () => {
return !validationError.value;
};
defineExpose({ validate });
const resetValidation = () => {
showErrors.value = false;
};
defineExpose({ validate, resetValidation });
</script>
<template>
@@ -166,18 +170,20 @@ defineExpose({ validate });
<FilterSelect
v-model="filterOperator"
variant="ghost"
:options="currentFilter.filterOperators"
:options="currentFilter?.filterOperators"
/>
<template v-if="currentOperator.hasInput">
<template v-if="currentOperator?.hasInput">
<MultiSelect
v-if="inputType === 'multiSelect'"
v-model="values"
:options="currentFilter.options"
dropdown-max-height="max-h-72"
/>
<SingleSelect
v-else-if="inputType === 'searchSelect'"
v-model="values"
:options="currentFilter.options"
dropdown-max-height="max-h-64"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
@@ -45,7 +45,7 @@ const { height } = useWindowSize();
const { height: dropdownHeight } = useElementBounding(dropdownRef);
const selectedOption = computed(() => {
return props.options.find(o => o.value === selected.value) || {};
return props.options?.find(o => o.value === selected.value) || {};
});
const iconToRender = computed(() => {
@@ -87,18 +87,25 @@ const updateSelected = newValue => {
</template>
<DropdownBody
ref="dropdownRef"
class="min-w-48 z-50"
class="min-w-56 z-50"
:class="dropdownPosition"
strong
>
<DropdownSection class="[&>ul]:max-h-80">
<DropdownItem
v-for="option in options"
:key="option.value"
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
<DropdownSection class="[&>ul]:max-h-72">
<template v-for="option in options" :key="option.value">
<li
v-if="option.disabled"
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
>
{{ option.label }}
</li>
<DropdownItem
v-else
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</template>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
@@ -8,7 +8,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options, maxChips } = defineProps({
const { options, maxChips, dropdownMaxHeight } = defineProps({
options: {
type: Array,
required: true,
@@ -17,6 +17,10 @@ const { options, maxChips } = defineProps({
type: Number,
default: 3,
},
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
});
const { t } = useI18n();
@@ -123,7 +127,7 @@ const toggleOption = option => {
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-50" strong>
<DropdownSection class="[&>ul]:max-h-80">
<DropdownSection :height="dropdownMaxHeight">
<DropdownItem
v-for="option in options"
:key="option.id"
@@ -12,10 +12,12 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const {
options,
disableSearch,
disableDeselect,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
dropdownMaxHeight,
} = defineProps({
options: {
type: Array,
@@ -41,6 +43,14 @@ const {
type: String,
default: '',
},
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
disableDeselect: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -63,6 +73,8 @@ const selectedItem = computed(() => {
const optionToSearch = Array.isArray(selected.value)
? selected.value[0]
: selected.value;
if (!optionToSearch) return null;
// extract the selected item from the options array
// this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id);
@@ -77,7 +89,7 @@ const toggleSelected = option => {
};
if (selected.value && selected.value.id === optionToToggle.id) {
selected.value = null;
if (!disableDeselect) selected.value = null;
} else {
selected.value = optionToToggle;
}
@@ -124,7 +136,7 @@ const toggleSelected = option => {
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="[&>ul]:max-h-80">
<DropdownSection :height="dropdownMaxHeight">
<template v-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
@@ -82,7 +82,7 @@ export function useOperators() {
hasInput: !NO_INPUT_OPTS.includes(value),
inputOverride: OPS_INPUT_OVERRIDE[value] || null,
icon: h('span', {
class: `${filterOperatorIcon[value]} !text-n-blue-text`,
class: `${filterOperatorIcon[value]} !text-n-blue-11`,
}),
};
return acc;
@@ -3,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue';
import { useTimeoutFn } from '@vueuse/core';
import { provideMessageContext } from './provider.js';
import { useTrack } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
MESSAGE_TYPES,
@@ -41,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
@@ -139,6 +142,9 @@ const showBackgroundHighlight = ref(false);
const showContextMenu = ref(false);
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
@@ -162,6 +168,10 @@ const variant = computed(() => {
if (props.contentAttributes?.isUnsupported)
return MESSAGE_VARIANTS.UNSUPPORTED;
if (props.contentAttributes?.externalEcho) {
return MESSAGE_VARIANTS.AGENT;
}
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
return MESSAGE_VARIANTS.BOT;
@@ -303,6 +313,7 @@ const componentToRender = computed(() => {
const instagramSharedTypes = [
ATTACHMENT_TYPES.STORY_MENTION,
ATTACHMENT_TYPES.IG_STORY,
ATTACHMENT_TYPES.IG_STORY_REPLY,
ATTACHMENT_TYPES.IG_POST,
];
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
@@ -380,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
);
});
@@ -423,6 +438,18 @@ function handleReplyTo() {
}
const avatarInfo = computed(() => {
if (props.contentAttributes?.externalEcho) {
const { name, avatar_url, channel_type, medium } = inbox.value;
const iconName = avatar_url
? null
: getInboxIconByType(channel_type, medium);
return {
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
src: avatar_url || '',
iconName,
};
}
// If no sender, return bot info
if (!props.sender) {
return {
@@ -450,6 +477,9 @@ const avatarInfo = computed(() => {
});
const avatarTooltip = computed(() => {
if (props.contentAttributes?.externalEcho) {
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
}
if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
});
@@ -483,7 +513,7 @@ provideMessageContext({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex mb-2 w-full message-bubble-container"
class="flex w-full mb-2 message-bubble-container"
:data-message-id="props.id"
:class="[
flexOrientationClass,
@@ -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>
@@ -163,7 +163,7 @@ const getInReplyToMessage = parentMessage => {
</script>
<template>
<ul class="px-4 bg-n-background">
<ul class="px-4 bg-n-surface-1">
<slot name="beforeAll" />
<template v-for="(message, index) in allMessages" :key="message.id">
<slot
@@ -7,6 +7,7 @@ import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
@@ -80,7 +81,7 @@ const replyToPreview = computed(() => {
const { content, attachments } = inReplyTo.value;
if (content) return content;
if (content) return new MessageFormatter(content).formattedMessage;
if (attachments?.length) {
const firstAttachment = attachments[0];
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
@@ -107,9 +108,10 @@ const replyToPreview = computed(() => {
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
@click="scrollToMessage"
>
<span class="break-all line-clamp-2">
{{ replyToPreview }}
</span>
<div
v-dompurify-html="replyToPreview"
class="prose prose-bubble line-clamp-2"
/>
</div>
<slot />
<MessageMeta
@@ -1,19 +1,26 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import Icon from 'next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS } from '../constants';
import { MESSAGE_VARIANTS, ATTACHMENT_TYPES } from '../constants';
const emit = defineEmits(['error']);
const { variant, content, attachments } = useMessageContext();
const { t } = useI18n();
const { variant, content, contentAttributes, attachments } =
useMessageContext();
const attachment = computed(() => {
return attachments.value[0];
});
const isStoryReply = computed(() => {
return contentAttributes.value?.imageType === ATTACHMENT_TYPES.IG_STORY_REPLY;
});
const hasImgStoryError = ref(false);
const hasVideoStoryError = ref(false);
@@ -38,6 +45,9 @@ const onVideoLoadError = () => {
<template>
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
<p v-if="isStoryReply" class="mb-1 text-xs text-n-slate-11">
{{ t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_REPLY') }}
</p>
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
<img
v-if="!hasImgStoryError"
@@ -22,7 +22,7 @@ defineProps({
/>
</div>
<div class="flex gap-2">
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
<Button :label="buttonText" slate class="!text-n-blue-11 w-full" />
</div>
</div>
</template>
@@ -18,12 +18,8 @@ defineProps({
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-text w-full" />
<Button
label="Visit our website"
slate
class="!text-n-blue-text w-full"
/>
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
</div>
</div>
</template>
@@ -1,9 +1,27 @@
<script setup>
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
import { useInbox } from 'dashboard/composables/useInbox';
import BaseBubble from './Base.vue';
const { inboxId } = useMessageContext();
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
inboxId.value
);
const unsupportedMessageKey = computed(() => {
if (isAFacebookInbox.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK';
if (isAnInstagramChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
});
</script>
<template>
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
{{ $t(unsupportedMessageKey) }}
</BaseBubble>
</template>
@@ -52,6 +52,7 @@ export const ATTACHMENT_TYPES = {
EMBED: 'embed',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
IG_STORY_REPLY: 'ig_story_reply',
};
export const CONTENT_TYPES = {
@@ -71,7 +71,7 @@ const pageInfo = computed(() => {
<template>
<div
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
>
<div class="flex items-center gap-3">
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
@@ -8,6 +8,7 @@ const props = defineProps({
type: String,
required: true,
},
// eslint-disable-next-line vue/no-unused-properties
active: {
type: Boolean,
default: false,
@@ -24,10 +25,7 @@ const reauthorizationRequired = computed(() => {
</script>
<template>
<span
class="size-5 grid place-content-center rounded-full bg-n-alpha-2"
:class="{ 'bg-n-solid-blue': active }"
>
<span class="size-5 grid place-content-center rounded-full bg-n-alpha-2">
<ChannelIcon :inbox="inbox" class="size-3" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
@@ -39,7 +39,7 @@ const toggleSidebar = () => {
<div
v-if="!isConversationRoute"
id="mobile-sidebar-launcher"
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-in-out block md:hidden"
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-out block md:hidden"
:class="[
{
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
@@ -1,6 +1,6 @@
<script setup>
import { h, ref, computed, onMounted } from 'vue';
import { provideSidebarContext } from './provider';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
@@ -8,6 +8,8 @@ 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';
@@ -15,7 +17,9 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import SidebarChangelogButton from './SidebarChangelogButton.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
@@ -42,6 +46,22 @@ const { t } = useI18n();
const isACustomBrandedInstance = useMapGetter(
'globalConfig/isACustomBrandedInstance'
);
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) {
@@ -58,11 +78,85 @@ const expandedItem = ref(null);
const setExpandedItem = name => {
expandedItem.value = expandedItem.value === name ? null : name;
};
const {
sidebarWidth,
isCollapsed,
setSidebarWidth,
saveWidth,
snapToCollapsed,
snapToExpanded,
COLLAPSED_THRESHOLD,
} = useSidebarResize();
// On mobile, sidebar is always expanded (flyout mode)
const isEffectivelyCollapsed = computed(
() => !isMobile.value && isCollapsed.value
);
// Resize handle logic
const isResizing = ref(false);
const startX = ref(0);
const startWidth = ref(0);
provideSidebarContext({
expandedItem,
setExpandedItem,
isCollapsed: isEffectivelyCollapsed,
sidebarWidth,
isResizing,
});
// Get clientX from mouse or touch event
const getClientX = event =>
event.touches ? event.touches[0].clientX : event.clientX;
const onResizeStart = event => {
isResizing.value = true;
startX.value = getClientX(event);
startWidth.value = sidebarWidth.value;
Object.assign(document.body.style, {
cursor: 'col-resize',
userSelect: 'none',
});
// Prevent default to avoid scrolling on touch
event.preventDefault();
};
const onResizeMove = event => {
if (!isResizing.value) return;
const delta = isRTL.value
? startX.value - getClientX(event)
: getClientX(event) - startX.value;
setSidebarWidth(startWidth.value + delta);
};
const onResizeEnd = () => {
if (!isResizing.value) return;
isResizing.value = false;
Object.assign(document.body.style, { cursor: '', userSelect: '' });
// Snap to collapsed state if below threshold
if (sidebarWidth.value < COLLAPSED_THRESHOLD) {
snapToCollapsed();
} else {
saveWidth();
}
};
const onResizeHandleDoubleClick = () => {
if (isCollapsed.value) snapToExpanded();
else snapToCollapsed();
};
// Support both mouse and touch events
useEventListener(document, 'mousemove', onResizeMove);
useEventListener(document, 'mouseup', onResizeEnd);
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const teams = useMapGetter('teams/getMyTeams');
@@ -192,6 +286,7 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
icon: h(ChannelIcon, { inbox, class: 'size-[12px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
h(ChannelLeaf, {
@@ -210,7 +305,7 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('label_conversations', {
@@ -338,7 +433,7 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute(
@@ -502,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'),
@@ -604,32 +703,56 @@ const menuItems = computed(() => {
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
]"
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
class="bg-n-background flex flex-col text-sm pb-0.5 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
:class="[
{
'shadow-lg md:shadow-none': isMobileSidebarOpen,
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
'transition-transform duration-200 ease-out md:transition-[width]':
!isResizing,
},
]"
:style="isMobile ? undefined : { width: `${sidebarWidth}px` }"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex gap-2 items-center px-2 min-w-0">
<div class="grid flex-shrink-0 place-content-center size-6">
<Logo class="size-4" />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
class="flex-grow -mx-1 min-w-0"
@show-create-account-modal="emit('showCreateAccountModal')"
/>
<section
class="grid"
:class="isEffectivelyCollapsed ? 'mt-3 mb-6 gap-4' : 'mt-1 mb-4 gap-2'"
>
<div
class="flex gap-2 items-center min-w-0"
:class="{
'justify-center px-1': isEffectivelyCollapsed,
'px-2': !isEffectivelyCollapsed,
}"
>
<template v-if="isEffectivelyCollapsed">
<SidebarAccountSwitcher
is-collapsed
@show-create-account-modal="emit('showCreateAccountModal')"
/>
</template>
<template v-else>
<div class="grid flex-shrink-0 place-content-center size-6">
<Logo class="size-4" />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
class="flex-grow -mx-1 min-w-0"
@show-create-account-modal="emit('showCreateAccountModal')"
/>
</template>
</div>
<div class="flex gap-2 px-2">
<div
class="flex gap-2"
:class="isEffectivelyCollapsed ? 'flex-col items-center' : 'px-2'"
>
<RouterLink
v-if="!isEffectivelyCollapsed"
:to="{ name: 'search' }"
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out"
>
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left">
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-10" />
<span class="flex-grow text-start text-n-slate-10">
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
</span>
<span
@@ -638,21 +761,41 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<RouterLink
v-else
:to="{ name: 'search' }"
class="flex items-center justify-center size-8 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out hover:bg-n-alpha-2 dark:hover:bg-n-slate-9/30"
:title="t('COMBOBOX.SEARCH_PLACEHOLDER')"
>
<span class="i-lucide-search size-4 text-n-slate-11" />
</RouterLink>
<ComposeConversation align-position="right" @close="onComposeClose">
<template #trigger="{ toggle }">
<template #trigger="{ toggle, isOpen }">
<Button
icon="i-lucide-pen-line"
color="slate"
size="sm"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
class="dark:hover:!bg-n-slate-9/30"
:class="[
isEffectivelyCollapsed
? '!size-8 !outline-n-weak !text-n-slate-11'
: '!h-7 !outline-n-weak !text-n-slate-11',
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
]"
@click="onComposeOpen(toggle)"
/>
</template>
</ComposeConversation>
</div>
</section>
<nav class="grid overflow-y-scroll flex-grow gap-2 px-2 pb-5 no-scrollbar">
<ul class="flex flex-col gap-1.5 m-0 list-none">
<nav
class="grid overflow-y-scroll flex-grow gap-2 pb-5 no-scrollbar min-w-0"
:class="isEffectivelyCollapsed ? 'px-1' : 'px-2'"
>
<ul
class="flex flex-col gap-1 m-0 list-none min-w-0"
:class="{ 'items-center': isEffectivelyCollapsed }"
>
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
@@ -664,18 +807,43 @@ const menuItems = computed(() => {
class="flex relative flex-col flex-shrink-0 gap-1 justify-between items-center"
>
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
class="pointer-events-none absolute inset-x-0 -top-[1.938rem] h-8 bg-gradient-to-t from-n-background to-transparent"
/>
<SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
v-if="
isOnChatwootCloud &&
!isACustomBrandedInstance &&
!isEffectivelyCollapsed
"
/>
<SidebarChangelogButton
v-if="
isOnChatwootCloud &&
!isACustomBrandedInstance &&
isEffectivelyCollapsed
"
/>
<div
class="p-1 flex-shrink-0 flex w-full justify-between z-10 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
class="p-1 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
>
<SidebarProfileMenu
:is-collapsed="isEffectivelyCollapsed"
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
</div>
</section>
<!-- Resize Handle (desktop only) -->
<div
class="hidden md:block absolute top-0 h-full w-1 cursor-col-resize z-40 ltr:right-0 rtl:left-0 group"
@mousedown="onResizeStart"
@touchstart="onResizeStart"
@dblclick="onResizeHandleDoubleClick"
>
<div
class="absolute top-0 h-full w-px ltr:right-0 rtl:left-0 bg-transparent group-hover:bg-n-brand transition-colors"
:class="{ 'bg-n-brand': isResizing }"
/>
</div>
</aside>
</template>
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import ButtonNext from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
import Logo from 'next/icon/Logo.vue';
import {
DropdownContainer,
@@ -13,6 +14,13 @@ import {
DropdownItem,
} from 'next/dropdown-menu/base';
defineProps({
isCollapsed: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['showCreateAccountModal']);
const { t } = useI18n();
@@ -45,7 +53,19 @@ const emitNewAccount = () => {
<template>
<DropdownContainer>
<template #trigger="{ toggle, isOpen }">
<!-- Collapsed view: Logo trigger -->
<button
v-if="isCollapsed"
class="grid flex-shrink-0 place-content-center p-2 rounded-lg cursor-pointer hover:bg-n-alpha-1"
:class="{ 'bg-n-alpha-1': isOpen }"
:title="currentAccount.name"
@click="toggle"
>
<Logo class="size-7" />
</button>
<!-- Expanded view: Account name trigger -->
<button
v-else
id="sidebar-account-switcher"
:data-account-id="accountId"
aria-haspopup="listbox"
@@ -73,7 +93,10 @@ const emitNewAccount = () => {
/>
</button>
</template>
<DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50">
<DropdownBody
v-if="showAccountSwitcher || isCollapsed"
class="min-w-80 z-50"
>
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
<DropdownItem
v-for="account in sortedCurrentUserAccounts"
@@ -0,0 +1,46 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
const [isOpen, toggleOpen] = useToggle(false);
const changelogCard = useTemplateRef('changelogCard');
const isLoading = computed(() => changelogCard.value?.isLoading || false);
const hasArticles = computed(
() => changelogCard.value?.unDismissedPosts?.length > 0
);
const shouldShowButton = computed(() => !isLoading.value && hasArticles.value);
const closePopover = () => {
if (isOpen.value) {
toggleOpen(false);
}
};
</script>
<template>
<div v-on-click-outside="closePopover" class="relative mb-2">
<Button
v-if="shouldShowButton"
icon="i-lucide-sparkles"
ghost
slate
:class="{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen }"
@click="toggleOpen()"
/>
<!-- Always render card so it can fetch data, control visibility with v-show -->
<div
v-show="isOpen && hasArticles"
class="absolute ltr:left-full rtl:right-full bottom-0 ltr:ml-4 rtl:mr-4 z-40 bg-transparent w-52"
>
<SidebarChangelogCard
ref="changelogCard"
class="[&>div]:!pb-0 [&>div]:!px-0 rounded-lg"
/>
</div>
</div>
</template>
@@ -4,6 +4,10 @@ import GroupedStackedChangelogCard from 'dashboard/components-next/changelog-car
import { useUISettings } from 'dashboard/composables/useUISettings';
import changelogAPI from 'dashboard/api/changelog';
defineOptions({
inheritAttrs: false,
});
const MAX_DISMISSED_SLUGS = 5;
const { uiSettings, updateUISettings } = useUISettings();
@@ -90,6 +94,11 @@ const handleImgClick = ({ index }) => {
handleReadMore();
};
defineExpose({
isLoading,
unDismissedPosts,
});
onMounted(() => {
fetchChangelog();
});
@@ -98,6 +107,7 @@ onMounted(() => {
<template>
<GroupedStackedChangelogCard
v-if="unDismissedPosts.length > 0"
v-bind="$attrs"
:posts="unDismissedPosts"
:current-index="currentIndex"
:dismissing-slugs="dismissingCards"
@@ -0,0 +1,198 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
label: { type: String, required: true },
children: { type: Array, default: () => [] },
activeChild: { type: Object, default: undefined },
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
});
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
const router = useRouter();
const { isAllowed, sidebarWidth } = useSidebarContext();
const expandedSubGroup = ref(null);
const popoverRef = ref(null);
const topPosition = ref(0);
const isRTL = useMapGetter('accounts/isRTL');
const skipTransition = ref(true);
const toggleSubGroup = name => {
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
};
const navigateAndClose = to => {
router.push(to);
emit('close');
};
const isActive = child => props.activeChild?.name === child.name;
const getAccessibleSubChildren = children =>
children.filter(c => isAllowed(c.to));
const renderIcon = icon => ({
component: typeof icon === 'object' ? icon : Icon,
props: typeof icon === 'string' ? { icon } : null,
});
const transition = computed(() =>
skipTransition.value
? {}
: {
enterActiveClass: 'transition-all duration-200 ease-out',
enterFromClass: 'opacity-0 -translate-y-2 max-h-0',
enterToClass: 'opacity-100 translate-y-0 max-h-96',
leaveActiveClass: 'transition-all duration-150 ease-in',
leaveFromClass: 'opacity-100 translate-y-0 max-h-96',
leaveToClass: 'opacity-0 -translate-y-2 max-h-0',
}
);
const accessibleChildren = computed(() => {
return props.children.filter(child => {
if (child.children) {
return child.children.some(subChild => isAllowed(subChild.to));
}
return child.to && isAllowed(child.to);
});
});
onMounted(async () => {
await nextTick();
// Auto-expand subgroup if active child is inside it
if (props.activeChild) {
const parentGroup = props.children.find(child =>
child.children?.some(subChild => subChild.name === props.activeChild.name)
);
if (parentGroup) {
expandedSubGroup.value = parentGroup.name;
// Wait for the subgroup expansion to render before measuring height
await nextTick();
}
}
if (!props.triggerRect) return;
const viewportHeight = window.innerHeight;
const popoverHeight = popoverRef.value?.offsetHeight || 300;
const { top: triggerTop } = props.triggerRect;
// Adjust position if popover would overflow viewport
topPosition.value =
triggerTop + popoverHeight > viewportHeight - 20
? Math.max(20, viewportHeight - popoverHeight - 20)
: triggerTop;
await nextTick();
skipTransition.value = false;
});
</script>
<template>
<TeleportWithDirection>
<div
ref="popoverRef"
class="fixed z-[100] min-w-[200px] max-w-[280px]"
:style="{
[isRTL ? 'right' : 'left']: `${sidebarWidth + 8}px`,
top: `${topPosition}px`,
}"
@mouseenter="emit('mouseenter')"
@mouseleave="emit('mouseleave')"
>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 -outline-offset-1 w-56 outline-n-weak rounded-xl shadow-lg py-2 px-2"
>
<div
class="px-2 py-1.5 text-xs font-medium text-n-slate-11 uppercase tracking-wider border-b border-n-weak mb-1"
>
{{ label }}
</div>
<ul
class="m-0 p-0 list-none max-h-[400px] overflow-y-auto no-scrollbar"
>
<template v-for="child in accessibleChildren" :key="child.name">
<!-- SubGroup with children -->
<li v-if="child.children" class="py-0.5">
<button
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
@click="toggleSubGroup(child.name)"
>
<Icon
v-if="child.icon"
:icon="child.icon"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
<span
class="size-3 transition-transform i-lucide-chevron-down"
:class="{
'rotate-180': expandedSubGroup === child.name,
}"
/>
</button>
<Transition v-bind="transition">
<ul
v-if="expandedSubGroup === child.name"
class="m-0 p-0 list-none ltr:pl-4 rtl:pr-4 mt-1 overflow-hidden"
>
<li
v-for="subChild in getAccessibleSubChildren(child.children)"
:key="subChild.name"
class="py-0.5"
>
<button
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
:class="{
'text-n-slate-12 bg-n-alpha-2': isActive(subChild),
'text-n-slate-11 hover:bg-n-alpha-2':
!isActive(subChild),
}"
@click="navigateAndClose(subChild.to)"
>
<component
:is="renderIcon(subChild.icon).component"
v-if="subChild.icon"
v-bind="renderIcon(subChild.icon).props"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
</button>
</li>
</ul>
</Transition>
</li>
<!-- Direct child item -->
<li v-else class="py-0.5">
<button
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
:class="{
'text-n-slate-12 bg-n-alpha-2': isActive(child),
'text-n-slate-11 hover:bg-n-alpha-2': !isActive(child),
}"
@click="navigateAndClose(child.to)"
>
<component
:is="renderIcon(child.icon).component"
v-if="child.icon"
v-bind="renderIcon(child.icon).props"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
</button>
</li>
</template>
</ul>
</div>
</div>
</TeleportWithDirection>
</template>
@@ -1,12 +1,14 @@
<script setup>
import { computed, onMounted, watch, nextTick } from 'vue';
import { useSidebarContext } from './provider';
import { computed, onMounted, onUnmounted, watch, nextTick, ref } from 'vue';
import { useSidebarContext, usePopoverState } from './provider';
import { useRoute, useRouter } from 'vue-router';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'next/icon/Icon.vue';
import SidebarGroupHeader from './SidebarGroupHeader.vue';
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
import SidebarSubGroup from './SidebarSubGroup.vue';
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
import SidebarCollapsedPopover from './SidebarCollapsedPopover.vue';
const props = defineProps({
name: { type: String, required: true },
@@ -25,8 +27,18 @@ const {
resolvePermissions,
resolveFeatureFlag,
isAllowed,
isCollapsed,
isResizing,
} = useSidebarContext();
const {
activePopover,
setActivePopover,
closeActivePopover,
scheduleClose,
cancelClose,
} = usePopoverState();
const navigableChildren = computed(() => {
return props.children?.flatMap(child => child.children || child) || [];
});
@@ -39,6 +51,54 @@ const hasChildren = computed(
() => Array.isArray(props.children) && props.children.length > 0
);
// Use shared popover state - only one popover can be open at a time
const isPopoverOpen = computed(() => activePopover.value === props.name);
const triggerRef = ref(null);
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
const openPopover = () => {
if (triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect();
triggerRect.value = {
top: rect.top,
left: rect.left,
bottom: rect.bottom,
right: rect.right,
};
}
setActivePopover(props.name);
};
const closePopover = () => {
if (activePopover.value === props.name) {
closeActivePopover();
}
};
const handleMouseEnter = () => {
if (!hasChildren.value || isResizing.value) return;
cancelClose();
openPopover();
};
const handleMouseLeave = () => {
if (!hasChildren.value) return;
scheduleClose(200);
};
const handlePopoverMouseEnter = () => {
cancelClose();
};
const handlePopoverMouseLeave = () => {
scheduleClose(100);
};
// Close popover when mouse leaves the window
const handleWindowBlur = () => {
closeActivePopover();
};
const accessibleItems = computed(() => {
if (!hasChildren.value) return [];
return props.children.filter(child => {
@@ -107,6 +167,13 @@ const hasActiveChild = computed(() => {
return activeChild.value !== undefined;
});
const handleCollapsedClick = () => {
if (hasChildren.value && hasAccessibleChildren.value) {
const firstItem = accessibleItems.value[0];
router.push(firstItem.to);
}
};
const toggleTrigger = () => {
if (
hasAccessibleChildren.value &&
@@ -125,6 +192,13 @@ onMounted(async () => {
if (hasActiveChild.value) {
setExpandedItem(props.name);
}
window.addEventListener('blur', handleWindowBlur);
document.addEventListener('mouseleave', handleWindowBlur);
});
onUnmounted(() => {
window.removeEventListener('blur', handleWindowBlur);
document.removeEventListener('mouseleave', handleWindowBlur);
});
watch(
@@ -145,45 +219,82 @@ watch(
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="grid gap-1 text-sm cursor-pointer select-none"
class="grid gap-1 text-sm cursor-pointer select-none min-w-0"
>
<SidebarGroupHeader
:icon
:name
:label
:to
:getter-keys="getterKeys"
:is-active="isActive"
:has-active-child="hasActiveChild"
:expandable="hasChildren"
:is-expanded="isExpanded"
@toggle="toggleTrigger"
/>
<ul
v-if="hasChildren"
v-show="isExpanded || hasActiveChild"
class="grid m-0 list-none sidebar-group-children"
>
<template v-for="child in children" :key="child.name">
<SidebarSubGroup
v-if="child.children"
:label="child.label"
:icon="child.icon"
:children="child.children"
:is-expanded="isExpanded"
<!-- Collapsed State -->
<template v-if="isCollapsed">
<div
class="relative"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<component
:is="to && !hasChildren ? 'router-link' : 'button'"
ref="triggerRef"
:to="to && !hasChildren ? to : undefined"
type="button"
class="flex items-center justify-center size-10 rounded-lg"
:class="{
'text-n-slate-12 bg-n-alpha-2': isActive || hasActiveChild,
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
}"
:title="label"
@click="hasChildren ? handleCollapsedClick() : undefined"
>
<Icon v-if="icon" :icon="icon" class="size-4" />
</component>
<SidebarCollapsedPopover
v-if="hasChildren && isPopoverOpen"
:label="label"
:children="children"
:active-child="activeChild"
:trigger-rect="triggerRect"
@close="closePopover"
@mouseenter="handlePopoverMouseEnter"
@mouseleave="handlePopoverMouseLeave"
/>
<SidebarGroupLeaf
v-else-if="isAllowed(child.to)"
v-show="isExpanded || activeChild?.name === child.name"
v-bind="child"
:active="activeChild?.name === child.name"
/>
</template>
</ul>
<ul v-else-if="isExpandable && isExpanded">
<SidebarGroupEmptyLeaf />
</ul>
</div>
</template>
<!-- Expanded State -->
<template v-else>
<SidebarGroupHeader
:icon
:name
:label
:to
:getter-keys="getterKeys"
:is-active="isActive"
:has-active-child="hasActiveChild"
:expandable="hasChildren"
:is-expanded="isExpanded"
@toggle="toggleTrigger"
/>
<ul
v-if="hasChildren"
v-show="isExpanded || hasActiveChild"
class="grid m-0 list-none sidebar-group-children min-w-0"
>
<template v-for="child in children" :key="child.name">
<SidebarSubGroup
v-if="child.children"
:label="child.label"
:icon="child.icon"
:children="child.children"
:is-expanded="isExpanded"
:active-child="activeChild"
/>
<SidebarGroupLeaf
v-else-if="isAllowed(child.to)"
v-show="isExpanded || activeChild?.name === child.name"
v-bind="child"
:active="activeChild?.name === child.name"
/>
</template>
</ul>
<ul v-else-if="isExpandable && isExpanded">
<SidebarGroupEmptyLeaf />
</ul>
</template>
</Policy>
</template>
@@ -26,13 +26,13 @@ const count = computed(() =>
<template>
<component
:is="to ? 'router-link' : 'div'"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 min-w-0"
class="flex items-center gap-2 px-1.5 py-1 rounded-lg h-8 min-w-0"
role="button"
draggable="false"
:to="to"
:title="label"
:class="{
'text-n-blue-text bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
'text-n-slate-12 bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
'text-n-slate-12 font-medium': hasActiveChild,
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
}"
@@ -45,15 +45,21 @@ const count = computed(() =>
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
/>
</div>
<div class="flex items-center gap-1.5 flex-grow min-w-0">
<span class="text-sm font-medium leading-5 truncate">
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
<span
class="truncate"
:class="{
'text-body-main': !isActive,
'font-medium text-sm': isActive || hasActiveChild,
}"
>
{{ label }}
</span>
<span
v-if="dynamicCount && !expandable"
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
:class="{
'text-n-blue-text outline-n-slate-6': isActive,
'text-n-slate-12 outline-n-slate-6': isActive,
'text-n-slate-11 outline-n-strong': !isActive,
}"
>
@@ -25,15 +25,15 @@ const shouldRenderComponent = computed(() => {
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="py-0.5 ltr:pl-3 rtl:pr-3 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0"
class="py-0.5 ltr:pl-2 rtl:pr-2 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0 min-w-0"
>
<component
:is="to ? 'router-link' : 'div'"
:to="to"
:title="label"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[9.438rem] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
:class="{
'text-n-blue-text bg-n-alpha-2 active': active,
'text-n-slate-12 bg-n-alpha-2 active': active,
}"
>
<component
@@ -15,6 +15,10 @@ import {
} from 'next/dropdown-menu/base';
import CustomBrandPolicyWrapper from '../../components/CustomBrandPolicyWrapper.vue';
defineProps({
isCollapsed: { type: Boolean, default: false },
});
const emit = defineEmits(['close', 'openKeyShortcutModal']);
defineOptions({
@@ -120,11 +124,19 @@ const allowedMenuItems = computed(() => {
</script>
<template>
<DropdownContainer class="relative w-full min-w-0" @close="emit('close')">
<DropdownContainer
class="relative min-w-0"
:class="isCollapsed ? 'w-auto' : 'w-full'"
@close="emit('close')"
>
<template #trigger="{ toggle, isOpen }">
<button
class="flex gap-2 items-center p-1 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
:class="{ 'bg-n-alpha-1': isOpen }"
class="flex gap-2 items-center p-1 text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
:class="[
{ 'bg-n-alpha-1': isOpen },
isCollapsed ? 'justify-center' : 'w-full',
]"
:title="isCollapsed ? currentUser.available_name : undefined"
@click="toggle"
>
<Avatar
@@ -135,7 +147,7 @@ const allowedMenuItems = computed(() => {
class="flex-shrink-0"
rounded-full
/>
<div class="min-w-0">
<div v-if="!isCollapsed" class="min-w-0">
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
{{ currentUser.available_name }}
</div>
@@ -48,11 +48,15 @@ useEventListener(scrollableContainer, 'scroll', () => {
:icon
class="my-1"
/>
<ul v-if="children.length" class="m-0 list-none reset-base relative group">
<ul
v-if="children.length"
class="m-0 list-none reset-base relative group min-w-0"
>
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
<div
ref="scrollableContainer"
class="min-w-0"
:class="{
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
}"
@@ -68,7 +72,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
<div
v-if="isScrollable && isExpanded"
v-show="!scrollEnd"
class="absolute bg-gradient-to-t from-n-solid-2 w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
>
<svg
width="16"
@@ -1,9 +1,87 @@
import { inject, provide } from 'vue';
import { inject, provide, ref, computed } from 'vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
const SidebarControl = Symbol('SidebarControl');
const DEFAULT_WIDTH = 200;
const MIN_WIDTH = 56;
const COLLAPSED_THRESHOLD = 160;
const MAX_WIDTH = 320;
// Shared state for active popover (only one can be open at a time)
const activePopover = ref(null);
let globalCloseTimeout = null;
export function useSidebarResize() {
const { uiSettings, updateUISettings } = useUISettings();
const sidebarWidth = ref(uiSettings.value.sidebar_width || DEFAULT_WIDTH);
const isCollapsed = computed(() => sidebarWidth.value < COLLAPSED_THRESHOLD);
const setSidebarWidth = width => {
sidebarWidth.value = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width));
};
const saveWidth = () => {
updateUISettings({ sidebar_width: sidebarWidth.value });
};
const snapToCollapsed = () => {
sidebarWidth.value = MIN_WIDTH;
updateUISettings({ sidebar_width: MIN_WIDTH });
};
const snapToExpanded = () => {
sidebarWidth.value = DEFAULT_WIDTH;
updateUISettings({ sidebar_width: DEFAULT_WIDTH });
};
return {
sidebarWidth,
isCollapsed,
setSidebarWidth,
saveWidth,
snapToCollapsed,
snapToExpanded,
MIN_WIDTH,
MAX_WIDTH,
COLLAPSED_THRESHOLD,
DEFAULT_WIDTH,
};
}
export function usePopoverState() {
const setActivePopover = name => {
clearTimeout(globalCloseTimeout);
activePopover.value = name;
};
const closeActivePopover = () => {
activePopover.value = null;
};
const scheduleClose = (delay = 150) => {
clearTimeout(globalCloseTimeout);
globalCloseTimeout = setTimeout(() => {
closeActivePopover();
}, delay);
};
const cancelClose = () => {
clearTimeout(globalCloseTimeout);
};
return {
activePopover,
setActivePopover,
closeActivePopover,
scheduleClose,
cancelClose,
};
}
export function useSidebarContext() {
const context = inject(SidebarControl, null);
if (context === null) {
@@ -11,7 +89,6 @@ export function useSidebarContext() {
}
const router = useRouter();
const { shouldShow } = usePolicy();
const resolvePath = to => {
@@ -27,7 +27,7 @@ const updateValue = () => {
>
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
<span
class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-out"
:class="
modelValue
? 'translate-x-3 bg-n-background'
@@ -84,7 +84,7 @@ const showDivider = index => {
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[
activeTab === index
? 'text-n-blue-text scale-100'
? 'text-n-blue-11 scale-100'
: 'text-n-slate-10 scale-[0.98]',
]"
@click="selectTab(index)"
@@ -36,6 +36,11 @@ const props = defineProps({
},
focusOnMount: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
// Skip label-based dedup when the consumer already filters menuItems by ID.
// Prevents removing all same-name items when one is selected (e.g. duplicate agent names).
skipLabelDedup: { type: Boolean, default: false },
// When false, the dropdown won't auto-open on mount; it opens only on click/focus.
autoOpenDropdown: { type: Boolean, default: true },
});
const emit = defineEmits([
@@ -56,7 +61,7 @@ const modelValue = defineModel({
const tagInputRef = ref(null);
const tags = ref(props.modelValue);
const newTag = ref('');
const isFocused = ref(true);
const isFocused = ref(props.autoOpenDropdown);
const rules = computed(() => getValidationRules(props.type));
const v$ = useVuelidate(rules, { newTag });
@@ -74,11 +79,11 @@ const showInput = computed(() =>
const showDropdownMenu = computed(() =>
props.mode === MODE.SINGLE && tags.value.length >= 1
? false
: props.showDropdown
: props.showDropdown && isFocused.value
);
const filteredMenuItems = computed(() =>
buildTagMenuItems({
const filteredMenuItems = computed(() => {
const items = buildTagMenuItems({
mode: props.mode,
tags: tags.value,
menuItems: props.menuItems,
@@ -86,8 +91,14 @@ const filteredMenuItems = computed(() =>
isLoading: props.isLoading,
type: props.type,
isNewTagInValidType: isNewTagInValidType.value,
})
);
allowCreate: props.allowCreate,
skipLabelDedup: props.skipLabelDedup,
});
if (props.type !== INPUT_TYPES.TEXT) return items;
const query = newTag.value?.trim()?.toLowerCase();
if (!query) return items;
return items.filter(item => item.label?.toLowerCase().includes(query));
});
const emitDataOnAdd = value => {
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
@@ -112,10 +123,13 @@ const addTag = async () => {
return;
}
if (
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
props.allowCreate
) {
const isValidatedType = [INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(
props.type
);
if (!isValidatedType && !props.allowCreate && props.showDropdown) return;
if (isValidatedType || props.allowCreate) {
if (!(await v$.value.$validate())) return;
emitDataOnAdd(trimmedTag);
}
@@ -125,28 +139,31 @@ const addTag = async () => {
const removeTag = index => {
tags.value.splice(index, 1);
modelValue.value = tags.value;
emit('remove');
emit('remove', index);
};
const handleDropdownAction = async ({
email: emailAddress,
phoneNumber,
label,
...rest
}) => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (!props.showDropdown) return;
const isEmail = props.type === 'email';
newTag.value = isEmail ? emailAddress : phoneNumber;
const isEmail = props.type === INPUT_TYPES.EMAIL;
const tagValue = isEmail ? emailAddress : phoneNumber || label;
if (!(await v$.value.$validate())) return;
if (isEmail || props.type === INPUT_TYPES.TEL) {
newTag.value = tagValue;
if (!(await v$.value.$validate())) return;
}
const payload = isEmail
? { email: emailAddress, ...rest }
: { phoneNumber, ...rest };
emit('add', payload);
updateValueAndFocus(emailAddress);
emit(
'add',
isEmail ? { email: emailAddress, ...rest } : { phoneNumber, label, ...rest }
);
updateValueAndFocus(tagValue);
};
const handleFocus = () => {
@@ -163,7 +180,7 @@ const handleKeydown = event => {
};
const handleClickOutside = () => {
if (tags.value.length) isFocused.value = false;
isFocused.value = false;
emit('onClickOutside');
};
@@ -261,6 +261,71 @@ describe('tagInputHelper', () => {
});
expect(result).toEqual(menuItems);
});
it('does not create suggestion when allowCreate is false', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
allowCreate: false,
});
expect(result).toEqual([]);
});
it('still returns available menu items when allowCreate is false', () => {
const menuItems = [
{ label: 'Agent 1', value: '1' },
{ label: 'Agent 2', value: '2' },
];
const result = buildTagMenuItems({
...baseParams,
menuItems,
newTag: 'Agent',
allowCreate: false,
});
expect(result).toEqual(menuItems);
});
it('creates new item when allowCreate is true (default)', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
allowCreate: true,
});
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
label: 'test@example.com',
action: 'create',
});
});
it('skips label dedup when skipLabelDedup is true', () => {
const menuItems = [
{ label: 'HDMA', value: 1 },
{ label: 'HDMA', value: 2 },
];
const result = buildTagMenuItems({
...baseParams,
tags: ['HDMA'],
menuItems,
skipLabelDedup: true,
});
expect(result).toEqual(menuItems);
});
it('filters by label when skipLabelDedup is false (default)', () => {
const menuItems = [
{ label: 'HDMA', value: 1 },
{ label: 'HDMA', value: 2 },
];
const result = buildTagMenuItems({
...baseParams,
tags: ['HDMA'],
menuItems,
});
expect(result).toEqual([]);
});
});
describe('canAddTag', () => {
@@ -84,25 +84,29 @@ export const buildTagMenuItems = ({
isLoading,
type,
isNewTagInValidType,
allowCreate = true,
skipLabelDedup = false,
}) => {
if (mode === MODE.SINGLE && tags.length >= 1) return [];
const availableMenuItems = menuItems.filter(
item => !tags.includes(item.label)
);
const availableMenuItems = skipLabelDedup
? menuItems
: menuItems.filter(item => !tags.includes(item.label));
// Show typed value as suggestion only if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. Validation passes (email/phone) and There are no menu items available
// 4. allowCreate is enabled
const trimmedNewTag = newTag?.trim();
const shouldShowTypedValue =
const shouldShowCreateSuggestion =
allowCreate &&
trimmedNewTag &&
!tags.includes(trimmedNewTag) &&
!isLoading &&
!availableMenuItems.length;
if (shouldShowTypedValue) {
if (shouldShowCreateSuggestion) {
const { isValid, formattedValue } = validateAndFormatNewTag(
trimmedNewTag,
type,
@@ -47,7 +47,7 @@ const onToggle = () => {
</div>
<div class="flex flex-row">
<slot name="button" />
<div class="flex justify-end w-3 text-n-blue-text cursor-pointer">
<div class="flex justify-end w-3 text-n-blue-11 cursor-pointer">
<fluent-icon v-if="isOpen" size="24" icon="subtract" type="solid" />
<fluent-icon v-else size="24" icon="add" type="solid" />
</div>
@@ -55,7 +55,7 @@ const onToggle = () => {
</button>
<div
v-if="isOpen"
class="bg-n-background outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg"
class="outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg"
:class="compact ? 'p-0' : 'px-2 py-4'"
>
<slot />
@@ -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);
@@ -902,7 +904,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<template>
<div
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',

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