Compare commits

...
Author SHA1 Message Date
Sojan 816294e609 chore: fixes 2025-06-03 03:41:49 -05:00
Sojan 9813400237 chore:fixes 2025-06-03 03:33:45 -05:00
Sojan cece07d649 chore: teest 2025-06-03 03:15:15 -05:00
Shivam MishraandGitHub 02c4863d95 feat: improve article search ranking (#11640) 2025-06-03 12:01:17 +05:30
PranavandGitHub bae958334d feat: Update UI for Copilot (#11561)
- Updated UI for copilot
2025-06-02 22:02:03 -05:00
Sivin VargheseandGitHub a5fda8e118 fix: Style issue with CSAT reports table (#11653) 2025-06-02 14:08:01 +05:30
Shivam MishraandGitHub 8fa039e1c5 fix: route params not reacting to changes (#11651) 2025-06-02 13:57:42 +05:30
PranavandGitHub 4061f99114 fix: Force re-render the CSAT component when data changes (#11643) 2025-06-02 11:10:47 +05:30
f064b09776 feat: move embedding config to a yaml file (#11611)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-30 16:26:40 +05:30
Shivam MishraandGitHub 07a39f4b42 feat: Enforce role permissions on filtered page (#11638)
Fixes: https://github.com/chatwoot/chatwoot/issues/11610

Demo:
https://www.loom.com/share/c9181b42619044379ba01e0ac913801d?sid=e306fe30-ce80-47ac-83e5-92132a99f464
2025-05-30 15:19:42 +05:30
Sojan JoseandGitHub 8bbf6c75e3 chore: Improvements to codespaces (#11635)
- Remove the deprecated extensions
- Install claude code in base image 
- Fix rails deprecation warning
2025-05-29 21:18:02 -06:00
PranavandGitHub aad6d655d5 fix: Update design to fix the crowded header (#11633)
**Before:**

<img width="907" alt="Screenshot 2025-05-29 at 3 21 00 PM"
src="https://github.com/user-attachments/assets/7738f684-7e9f-40ff-ac49-d9b389eca99b"
/>

**After:**
<img width="903" alt="Screenshot 2025-05-29 at 3 20 33 PM"
src="https://github.com/user-attachments/assets/1213d832-59d8-4d04-be96-f711297a887d"
/>
2025-05-29 18:45:28 -06:00
PranavandGitHub 70c29f699c fix(revert): "fix: Send CSAT survey only when agent can reply in conversati… (#11634)
…on (#11584)"

This reverts commit b5ebc47637.

# 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 # (issue)

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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:

- [ ] 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
2025-05-29 16:52:33 -06:00
Sojan JoseandGitHub 4ab0b17e6e feat: add custom roles seeding to account seeder (#11623)
Introduce custom roles in account seed data to support testing of
realistic role-based access scenarios. Includes six distinct roles with
varied permission sets and assigns them to multiple users for
comprehensive test coverage.
2025-05-29 15:17:20 -06:00
Sojan JoseandGitHub 3548948c92 fix: Allow users with report_manage permission to access CSAT reports (#11625)
- Extended CsatSurveyResponsePolicy to support report_manage permission
- Added enterprise extension module following existing pattern
- Users with report_manage custom role can now access CSAT index,
metrics, and download
- Added comprehensive tests for both base and enterprise policy behavior
2025-05-29 12:09:03 -06:00
Sivin VargheseandGitHub b1898e019b fix: Design issues with profile settings style (#11630)
# Pull Request Template

## Description

This PR fixes styles issues with the select in profile settings and
toggles the Hotkeys button.

## Type of change

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

## How Has This Been Tested?

### Screenshots

**Select**
| Before  | After (Dark Mode) | After (Light Mode) |
| ------------- | ------------- | ------------- |
| <img width="162" alt="image"
src="https://github.com/user-attachments/assets/6d4ebd45-d233-413e-aca8-7f4db5d6c8a2"
/> <img width="699" alt="image"
src="https://github.com/user-attachments/assets/62c2ca85-479f-4252-a4fc-ffd5a570cff0"
/> | <img width="162" alt="image"
src="https://github.com/user-attachments/assets/7c9780ea-8ab6-429e-82e3-6fb554141bc0"
/> <img width="699" alt="image"
src="https://github.com/user-attachments/assets/726d7a0a-bfbc-4bdc-aedc-a874146230d8"
/> | <img width="162" alt="image"
src="https://github.com/user-attachments/assets/f3022b5f-6ae2-48d6-a2c9-9961f4295b81"
/> <img width="699" alt="image"
src="https://github.com/user-attachments/assets/fa6515c1-35df-4f6f-9096-e7893bffe683"
/> |

**Toggle Hotkeys**
| Before  | After (Dark Mode) | After (Light Mode) |
| ------------- | ------------- | ------------- |
| <img width="783" alt="image"
src="https://github.com/user-attachments/assets/5628fa6a-9692-44f4-bc45-6e5de44588c7"
/> | <img width="783" alt="image"
src="https://github.com/user-attachments/assets/14529e80-fd9c-46cd-9ad8-aab041781238"
/> | <img width="783" alt="image"
src="https://github.com/user-attachments/assets/dfe2cbec-8d05-443d-89a2-0e674fffa518"
/> |

## 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
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-05-29 12:07:05 -06:00
Shivam MishraandGitHub b3a76289cc feat: use tracking pixel for article view count (#11559) 2025-05-29 17:01:38 +05:30
873cfa08d8 feat: Ability to reset api_access_token (#11565)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-05-29 14:42:13 +05:30
a0cc27faaf feat: show articles in the list only if feature is enabled (#11607)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-29 14:19:35 +05:30
Sojan JoseandGitHub fdd35ff549 chore: Update Codespaces (#11621)
- Fix issues with the current Chatwoot development codespaces
- Switch from webpacket to vite 
- Add additional configs to make the development easier with codespaces
- toggles v4 feature true as default
2025-05-29 01:45:00 -06:00
Sivin VargheseandGitHub 2ea10ae065 fix: Snackbar notifications hidden behind modal dialogs (#11616) 2025-05-29 12:49:38 +05:30
23a804512a feat: Update the UI to support the change for Copilot as a universal copilot (#11618)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-29 12:35:10 +05:30
Shivam MishraandGitHub f6510e0d43 docs: add swagger spec for accounts API (#11620) 2025-05-29 11:23:27 +05:30
Sivin VargheseandGitHub c3d98fc064 chore: Move URL comparison logic to utils (#11617) 2025-05-29 10:19:56 +05:30
133 changed files with 3938 additions and 1440 deletions
+11 -1
View File
@@ -4,5 +4,15 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
# Do the set up required for chatwoot app
WORKDIR /workspace
# Copy dependency files first for better caching
COPY package.json pnpm-lock.yaml ./
COPY Gemfile Gemfile.lock ./
# Install dependencies (will be cached if files don't change)
RUN pnpm install --frozen-lockfile && \
gem install bundler && \
bundle install --jobs=$(nproc)
# Copy source code after dependencies are installed
COPY . /workspace
RUN yarn && gem install bundler && bundle install
+65 -42
View File
@@ -1,12 +1,16 @@
ARG VARIANT
ARG VARIANT="ubuntu-22.04"
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
ENV DEBIAN_FRONTEND=noninteractive
ARG NODE_VERSION
ARG RUBY_VERSION
ARG USER_UID
ARG USER_GID
ARG PNPM_VERSION="10.2.0"
ENV PNPM_VERSION ${PNPM_VERSION}
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
@@ -15,61 +19,80 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
fi
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg2 \
tar \
tzdata \
postgresql-client \
libpq-dev \
yarn \
git \
imagemagick \
tmux \
zsh \
git-flow \
npm \
libyaml-dev
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get update \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg \
tar \
tzdata \
postgresql-client \
libpq-dev \
git \
imagemagick \
libyaml-dev \
curl \
ca-certificates \
tmux \
nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install rbenv and ruby
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
# Install rbenv and ruby for root user first
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
RUN git clone https://github.com/rbenv/ruby-build.git && \
RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \
PREFIX=/usr/local ./ruby-build/install.sh
RUN rbenv install $RUBY_VERSION && \
rbenv global $RUBY_VERSION && \
rbenv versions
# Install overmind
# Set up rbenv for vscode user
RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \
&& su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \
&& su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION"
# Install overmind and gh in single layer
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
&& gunzip overmind.gz \
&& sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind
# Install gh
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh
&& mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install -y --no-install-recommends gh \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Do the set up required for chatwoot app
WORKDIR /workspace
COPY . /workspace
RUN chown vscode:vscode /workspace
# set up ruby
COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install
# set up node js, pnpm and claude code in single layer
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \
&& npm cache clean --force
# set up node js
RUN npm install n -g && \
n $NODE_VERSION
RUN npm install --global yarn
RUN yarn
# Switch to vscode user
USER vscode
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH"
# Copy dependency files first for better caching
COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./
# Install dependencies as vscode user
RUN eval "$(rbenv init -)" \
&& gem install bundler -N \
&& bundle install --jobs=$(nproc) \
&& pnpm install --frozen-lockfile
# Copy source code after dependencies are installed
COPY --chown=vscode:vscode . /workspace
+17 -8
View File
@@ -4,17 +4,26 @@
"dockerComposeFile": "docker-compose.yml",
"settings": {
"terminal.integrated.shell.linux": "/bin/zsh"
"terminal.integrated.shell.linux": "/bin/zsh",
"extensions.showRecommendationsOnlyOnDemand": true,
"editor.formatOnSave": true,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"search.exclude": {
"**/node_modules": true,
"**/tmp": true,
"**/log": true,
"**/coverage": true,
"**/public/packs": true
}
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"rebornix.Ruby",
"Shopify.ruby-lsp",
"misogi.ruby-rubocop",
"wingrunr21.vscode-ruby",
"davidpallinder.rails-test-runner",
"eamodio.gitlens",
"github.copilot",
"mrmlnc.vscode-duplicate"
],
@@ -23,15 +32,15 @@
// 5432 postgres
// 6379 redis
// 1025,8025 mailhog
"forwardPorts": [8025, 3000, 3035],
"forwardPorts": [8025, 3000, 3036],
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn",
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install",
"portsAttributes": {
"3000": {
"label": "Rails Server"
},
"3035": {
"label": "Webpack Dev Server"
"3036": {
"label": "Vite Dev Server"
},
"8025": {
"label": "Mailhog UI"
+18
View File
@@ -0,0 +1,18 @@
# Docker Compose file for building the base image in GitHub Actions
# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base
version: '3'
services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: ghcr.io/chatwoot/chatwoot_codespace:latest
-13
View File
@@ -5,19 +5,6 @@
version: '3'
services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: base:latest
app:
build:
context: ..
+10 -7
View File
@@ -2,12 +2,15 @@ cp .env.example .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# uncomment the webpacker env variable
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
# fix the error with webpacker
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
# Setup Claude Code API key if available
if [ -n "$CLAUDE_CODE_API_KEY" ]; then
mkdir -p ~/.claude
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json
echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
chmod +x ~/.claude/anthropic_key.sh
fi
# codespaces make the ports public
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
@@ -19,6 +19,5 @@ jobs:
- name: Build the Codespace Base Image
run: |
docker-compose -f .devcontainer/docker-compose.yml build base
docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest
docker compose -f .devcontainer/docker-compose.base.yml build base
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
+1
View File
@@ -41,6 +41,7 @@ run:
force_run:
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev
debug:
+3
View File
@@ -0,0 +1,3 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: BUILD_MODE=dev bin/vite build --watch
@@ -29,6 +29,11 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
head :ok
end
def reset_access_token
@agent_bot.access_token.regenerate_token
@agent_bot.reload
end
private
def agent_bot
@@ -38,6 +38,11 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok
end
def reset_access_token
@user.access_token.regenerate_token
@user.reload
end
private
def set_user
@@ -1,7 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category, except: [:index, :show]
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show]
layout 'portal'
@@ -15,6 +15,21 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show; end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
return head :not_found unless @article
@article.increment_view_count if @article.published?
# Serve the 1x1 tracking pixel with 24-hour private cache
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
expires_in 24.hours, public: false
response.headers['Content-Type'] = 'image/png'
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
send_file pixel_path, type: 'image/png', disposition: 'inline'
end
private
def limit_results
@@ -39,7 +54,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content)
end
@@ -21,6 +21,10 @@ class AgentBotsAPI extends ApiClient {
deleteAgentBotAvatar(botId) {
return axios.delete(`${this.url}/${botId}/avatar`);
}
resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`);
}
}
export default new AgentBotsAPI();
+4
View File
@@ -102,4 +102,8 @@ export default {
const urlData = endPoints('resendConfirmation');
return axios.post(urlData.url);
},
resetAccessToken() {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
};
@@ -51,6 +51,9 @@ const endPoints = {
resendConfirmation: {
url: '/api/v1/profile/resend_confirmation',
},
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
};
export default page => {
@@ -134,10 +134,6 @@ class ConversationApi extends ApiClient {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
@@ -9,5 +9,6 @@ describe('#AgentBotsAPI', () => {
expect(AgentBotsAPI).toHaveProperty('create');
expect(AgentBotsAPI).toHaveProperty('update');
expect(AgentBotsAPI).toHaveProperty('delete');
expect(AgentBotsAPI).toHaveProperty('resetAccessToken');
});
});
@@ -101,7 +101,7 @@ select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-size: 9px 6px;
@apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
@apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
&[disabled] {
@apply field-disabled;
@@ -3,7 +3,7 @@
}
.tabs--container--with-border {
@apply border-b border-n-weak;
@apply border-b border-b-n-weak;
}
.tabs--container--compact.tab--chat-type {
@@ -0,0 +1,87 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMapGetter } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
const { updateUISettings } = useUISettings();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open
);
const toggleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false,
});
};
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: true,
is_copilot_panel_open: false,
});
};
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
};
const keyboardEvents = {
'Alt+KeyO': {
action: toggleConversationSidebarToggle,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex flex-col justify-center items-center absolute top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
ghost
slate
sm
class="!rounded-full"
:class="{
'bg-n-alpha-2': isContactSidebarOpen,
}"
icon="i-ph-user-bold"
@click="handleConversationSidebarToggle"
/>
<Button
v-if="showCopilotTab"
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
ghost
slate
class="!rounded-full"
:class="{
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
}"
sm
icon="i-woot-captain"
@click="handleCopilotSidebarToggle"
/>
</div>
</template>
@@ -1,21 +1,29 @@
<script setup>
import CopilotHeader from './CopilotHeader.vue';
import SidebarActionsHeader from './SidebarActionsHeader.vue';
</script>
<template>
<Story
title="Captain/Copilot/CopilotHeader"
title="Components/SidebarActionsHeader"
:layout="{ type: 'grid', width: '800px' }"
>
<!-- Default State -->
<Variant title="Default State">
<CopilotHeader />
<SidebarActionsHeader title="Default State" />
</Variant>
<!-- With New Conversation Button -->
<Variant title="With New Conversation Button">
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
<CopilotHeader :has-messages="true" />
<SidebarActionsHeader
title="With New Conversation Button"
:buttons="[
{
key: 'new_conversation',
icon: 'i-lucide-plus',
},
]"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,47 @@
<script setup>
import Button from './button/Button.vue';
defineProps({
title: {
type: String,
required: true,
},
buttons: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['click', 'close']);
const handleButtonClick = button => {
emit('click', button.key);
};
</script>
<template>
<div
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">{{ title }}</span>
<div class="flex items-center">
<Button
v-for="button in buttons"
:key="button.key"
v-tooltip="button.tooltip"
:icon="button.icon"
ghost
sm
@click="handleButtonClick(button)"
/>
<Button
v-tooltip="$t('GENERAL.CLOSE')"
icon="i-lucide-x"
ghost
sm
@click="$emit('close')"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,41 @@
<script setup>
import ConfirmButton from './ConfirmButton.vue';
import { ref } from 'vue';
const count = ref(0);
const incrementCount = () => {
count.value += 1;
};
</script>
<template>
<Story
title="Components/ConfirmButton"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Basic">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Delete"
confirm-label="Confirm?"
@click="incrementCount"
/>
</div>
</Variant>
<Variant title="Color Change">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Archive"
confirm-label="Confirm?"
color="slate"
confirm-color="amber"
@click="incrementCount"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { ref, computed } from 'vue';
import Button from './Button.vue';
const props = defineProps({
label: { type: [String, Number], default: '' },
confirmLabel: { type: [String, Number], default: '' },
color: { type: String, default: 'blue' },
confirmColor: { type: String, default: 'ruby' },
confirmHint: { type: String, default: '' },
variant: { type: String, default: null },
size: { type: String, default: null },
justify: { type: String, default: null },
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const isConfirmMode = ref(false);
const isClicked = ref(false);
const currentLabel = computed(() => {
return isConfirmMode.value ? props.confirmLabel : props.label;
});
const currentColor = computed(() => {
return isConfirmMode.value ? props.confirmColor : props.color;
});
const resetConfirmMode = () => {
isConfirmMode.value = false;
isClicked.value = false;
};
const handleClick = () => {
if (!isConfirmMode.value) {
isConfirmMode.value = true;
} else {
isClicked.value = true;
emit('click');
setTimeout(resetConfirmMode, 400);
}
};
</script>
<template>
<div
class="relative"
:class="{
'animate-bounce-complete': isClicked,
}"
>
<Button
type="button"
:label="currentLabel"
:color="currentColor"
:variant="variant"
:size="size"
:justify="justify"
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
@click="handleClick"
@blur="resetConfirmMode"
>
<template v-if="$slots.default" #default>
<slot />
</template>
<template v-if="$slots.icon" #icon>
<slot name="icon" />
</template>
</Button>
<div
v-if="isConfirmMode && confirmHint"
class="absolute mt-1 w-full text-[10px] text-center text-n-slate-10"
>
{{ confirmHint }}
</div>
</div>
</template>
<style scoped>
@keyframes bounce-complete {
0% {
transform: scale(0.95);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.animate-bounce-complete {
animation: bounce-complete 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
</style>
@@ -76,7 +76,7 @@ const handlePageChange = event => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 xl:px-0">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
@@ -116,7 +116,7 @@ const handlePageChange = event => {
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" />
<div
@@ -1,29 +1,24 @@
<script setup>
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { nextTick, ref, watch, computed } from 'vue';
import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useUISettings } from 'dashboard/composables/useUISettings';
import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import Icon from '../icon/Icon.vue';
import CopilotEmptyState from './CopilotEmptyState.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
supportAgent: {
type: Object,
default: () => ({}),
},
messages: {
type: Array,
default: () => [],
},
isCaptainTyping: {
type: Boolean,
default: false,
},
conversationInboxType: {
type: String,
required: true,
@@ -42,22 +37,11 @@ const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => {
emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
};
const useSuggestion = opt => {
emit('sendMessage', t(opt.prompt));
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const handleReset = () => {
emit('reset');
};
const chatContainer = ref(null);
const scrollToBottom = async () => {
@@ -67,23 +51,72 @@ const scrollToBottom = async () => {
}
};
const promptOptions = [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
];
const groupedMessages = computed(() => {
const result = [];
let thinkingGroup = [];
props.messages.forEach(message => {
if (message.message_type === 'assistant_thinking') {
thinkingGroup.push(message);
} else {
if (thinkingGroup.length > 0) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
thinkingGroup = [];
}
result.push(message);
}
});
if (thinkingGroup.length > 0) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
}
return result;
});
const isLastMessageFromAssistant = computed(() => {
return (
groupedMessages.value[groupedMessages.value.length - 1].message_type ===
'assistant'
);
});
const { updateUISettings } = useUISettings();
const closeCopilotPanel = () => {
updateUISettings({
is_copilot_panel_open: false,
is_contact_sidebar_open: false,
});
};
const handleSidebarAction = action => {
if (action === 'reset') {
emit('reset');
}
};
const hasAssistants = computed(() => props.assistants.length > 0);
const hasMessages = computed(() => props.messages.length > 0);
const copilotButtons = computed(() => {
if (hasMessages.value) {
return [
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: t('CAPTAIN.COPILOT.RESET'),
},
];
}
return [];
});
watch(
[() => props.messages, () => props.isCaptainTyping],
[() => props.messages],
() => {
scrollToBottom();
},
@@ -93,62 +126,59 @@ watch(
<template>
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
v-if="message.role === 'user'"
:support-agent="supportAgent"
:message="message"
/>
<CopilotAssistantMessage
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
:message="message"
:conversation-inbox-type="conversationInboxType"
/>
</template>
<CopilotLoader v-if="isCaptainTyping" />
</div>
<SidebarActionsHeader
:title="$t('CAPTAIN.COPILOT.TITLE')"
:buttons="copilotButtons"
@click="handleSidebarAction"
@close="closeCopilotPanel"
/>
<div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
ref="chatContainer"
class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
<div v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full">
<template v-for="(item, index) in groupedMessages" :key="item.id">
<CopilotAgentMessage
v-if="item.message_type === 'user'"
:message="item.message"
/>
<CopilotAssistantMessage
v-else-if="item.message_type === 'assistant'"
:message="item.message"
:is-last-message="index === groupedMessages.length - 1"
:conversation-inbox-type="conversationInboxType"
/>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" />
</div>
<CopilotEmptyState
v-else
:has-assistants="hasAssistants"
@use-suggestion="sendMessage"
/>
</div>
<div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant
v-if="assistants.length"
v-if="assistants.length > 1"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)"
/>
<div v-else />
<button
v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline"
@click="handleReset"
>
<i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button>
</div>
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
<CopilotInput
v-if="hasAssistants"
class="mb-1 w-full"
@send="sendMessage"
/>
</div>
</div>
</template>
@@ -11,6 +11,10 @@ import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
isLastMessage: {
type: Boolean,
default: false,
},
message: {
type: Object,
required: true,
@@ -20,6 +24,15 @@ const props = defineProps({
required: true,
},
});
const hasEmptyMessageContent = computed(() => !props.message?.content);
const showUseButton = computed(() => {
return (
!hasEmptyMessageContent.value &&
props.message.reply_suggestion &&
props.isLastMessage
);
});
const messageContent = computed(() => {
const formatter = new MessageFormatter(props.message.content);
@@ -32,8 +45,6 @@ const insertIntoRichEditor = computed(() => {
);
});
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => {
if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -57,7 +68,7 @@ const useCopilotResponse = () => {
/>
<div class="flex flex-row mt-1">
<Button
v-if="!hasEmptyMessageContent"
v-if="showUseButton"
:label="$t('CAPTAIN.COPILOT.USE')"
faded
sm
@@ -0,0 +1,108 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Icon from '../icon/Icon.vue';
defineProps({
hasAssistants: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['useSuggestion']);
const { t } = useI18n();
const route = useRoute();
const routePromptMap = {
conversations: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
],
dashboard: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.CONTENT',
},
],
};
const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/contacts')) return 'contacts';
if (path.includes('/articles')) return 'articles';
return 'dashboard';
};
const promptOptions = computed(() => {
const currentRoute = getCurrentRoute();
return routePromptMap[currentRoute] || routePromptMap.conversations;
});
const handleSuggestion = opt => {
emit('useSuggestion', t(opt.prompt));
};
</script>
<template>
<div class="flex-1 flex flex-col gap-6 px-2">
<div class="flex flex-col space-y-4 py-4">
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
<div class="space-y-1">
<h3 class="text-base font-medium text-n-slate-12 leading-8">
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
</p>
</div>
</div>
<div v-if="!hasAssistants" class="w-full space-y-2">
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.ASSISTANTS.NO_ASSISTANTS_AVAILABLE') }}
</p>
<router-link
:to="{
name: 'captain_assistants_index',
params: { accountId: route.params.accountId },
}"
class="text-n-slate-11 underline hover:text-n-slate-12"
>
{{ $t('CAPTAIN.ASSISTANTS.ADD_NEW') }}
</router-link>
</div>
<div v-else class="w-full space-y-2">
<span class="text-xs text-n-slate-10 block">
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
</span>
<div class="space-y-1">
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
@click="handleSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
</div>
</div>
</template>
@@ -1,32 +0,0 @@
<script setup>
import Button from '../button/Button.vue';
defineProps({
hasMessages: {
type: Boolean,
default: false,
},
});
defineEmits(['reset', 'close']);
</script>
<template>
<div
class="flex items-center justify-between px-5 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">
{{ $t('CAPTAIN.COPILOT.TITLE') }}
</span>
<div class="flex items-center">
<Button
v-if="hasMessages"
icon="i-lucide-plus"
ghost
sm
@click="$emit('reset')"
/>
<Button icon="i-lucide-x" ghost sm @click="$emit('close')" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,61 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotLauncher = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
return (
isCaptainEnabled &&
!uiSettings.value.is_copilot_panel_open &&
!isConversationRoute.value
);
});
const toggleSidebar = () => {
updateUISettings({
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
});
};
</script>
<template>
<div v-if="showCopilotLauncher" class="fixed bottom-4 right-4 z-50">
<div class="rounded-full bg-n-alpha-2 p-1">
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
<template v-else />
</template>
@@ -17,7 +17,7 @@ defineProps({
icon="i-lucide-sparkles"
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
/>
<div class="text-sm text-n-slate-11">
<div class="text-sm text-n-slate-12">
{{ content }}
</div>
</div>
@@ -51,10 +51,10 @@ watch(
}"
>
<CopilotThinkingBlock
v-for="message in messages"
:key="message.id"
:content="message.content"
:reasoning="message.reasoning"
v-for="copilotMessage in messages"
:key="copilotMessage.id"
:content="copilotMessage.message.content"
:reasoning="copilotMessage.message.reasoning"
/>
</div>
</div>
@@ -477,7 +477,7 @@ const menuItems = computed(() => {
<template>
<aside
class="w-[12.5rem] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
@@ -519,7 +519,7 @@ const menuItems = computed(() => {
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
<ul class="flex flex-col gap-2 m-0 list-none">
<ul class="flex flex-col gap-1.5 m-0 list-none">
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
@@ -814,7 +814,7 @@ watch(conversationFilters, (newVal, oldVal) => {
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
]"
>
<slot />
@@ -80,16 +80,14 @@ const toggleConversationLayout = () => {
<template>
<div
class="flex items-center justify-between gap-2 px-4"
class="flex items-center justify-between gap-2 px-3 h-12"
:class="{
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
'pt-3 pb-2': showV4View,
'mb-2 pb-0': !showV4View,
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex items-center justify-center min-w-0">
<h1
class="text-lg font-medium truncate text-n-slate-12"
class="text-base font-medium truncate text-n-slate-12"
:title="pageTitle"
>
{{ pageTitle }}
@@ -20,7 +20,7 @@ export default {
<template>
<div>
<div
class="shadow-sm bg-slate-800 dark:bg-slate-700 rounded-[4px] items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
class="shadow-sm bg-n-slate-12 dark:bg-n-slate-7 rounded-lg items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
>
<div class="text-sm font-medium text-white dark:text-white">
{{ message }}
@@ -29,7 +29,7 @@ export default {
<router-link
v-if="action.type == 'link'"
:to="action.to"
class="font-medium cursor-pointer select-none text-woot-500 dark:text-woot-500 hover:text-woot-600 dark:hover:text-woot-600"
class="font-medium cursor-pointer select-none text-n-blue-10 hover:text-n-brand"
>
{{ action.message }}
</router-link>
@@ -1,62 +1,72 @@
<script>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import WootSnackbar from './Snackbar.vue';
import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n';
export default {
components: {
WootSnackbar,
},
props: {
duration: {
type: Number,
default: 2500,
},
const props = defineProps({
duration: {
type: Number,
default: 2500,
},
});
data() {
return {
snackMessages: [],
};
},
const { t } = useI18n();
mounted() {
emitter.on('newToastMessage', this.onNewToastMessage);
},
unmounted() {
emitter.off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage({ message: originalMessage, action }) {
// FIX ME: This is a temporary workaround to pass string from functions
// that doesn't have the context of the VueApp.
const usei18n = action?.usei18n;
const duration = action?.duration || this.duration;
const message = usei18n ? this.$t(originalMessage) : originalMessage;
const snackMessages = ref([]);
const snackbarContainer = ref(null);
this.snackMessages.push({
key: new Date().getTime(),
message,
action,
});
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, duration);
},
},
const showPopover = () => {
try {
const el = snackbarContainer.value;
if (el?.matches(':popover-open')) {
el.hidePopover();
}
el?.showPopover();
} catch (e) {
// ignore
}
};
const onNewToastMessage = ({ message: originalMessage, action }) => {
const message = action?.usei18n ? t(originalMessage) : originalMessage;
const duration = action?.duration || props.duration;
snackMessages.value.push({
key: Date.now(),
message,
action,
});
nextTick(showPopover);
setTimeout(() => {
snackMessages.value.shift();
}, duration);
};
onMounted(() => {
emitter.on('newToastMessage', onNewToastMessage);
});
onUnmounted(() => {
emitter.off('newToastMessage', onNewToastMessage);
});
</script>
<template>
<transition-group
name="toast-fade"
tag="div"
class="left-0 my-0 mx-auto max-w-[25rem] overflow-hidden absolute right-0 text-center top-4 z-[9999]"
<div
ref="snackbarContainer"
popover="manual"
class="fixed top-4 left-1/2 -translate-x-1/2 max-w-[25rem] w-[calc(100%-2rem)] text-center bg-transparent border-0 p-0 m-0 outline-none overflow-visible"
>
<WootSnackbar
v-for="snackMessage in snackMessages"
:key="snackMessage.key"
:message="snackMessage.message"
:action="snackMessage.action"
/>
</transition-group>
<transition-group name="toast-fade" tag="div">
<WootSnackbar
v-for="snackMessage in snackMessages"
:key="snackMessage.key"
:message="snackMessage.message"
:action="snackMessage.action"
/>
</transition-group>
</div>
</template>
@@ -1,16 +1,11 @@
<script setup>
import { ref, computed, onMounted, watchEffect } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import ConversationAPI from 'dashboard/api/inbox/conversation';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
defineProps({
conversationInboxType: {
type: String,
required: true,
@@ -20,12 +15,24 @@ const props = defineProps({
const store = useStore();
const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const inboxAssistant = useMapGetter('getCopilotAssistant');
const { uiSettings, updateUISettings } = useUISettings();
const currentChat = useMapGetter('getSelectedChat');
const selectedCopilotThreadId = ref(null);
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](
selectedCopilotThreadId.value
)
);
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -55,68 +62,57 @@ const setAssistant = async assistant => {
});
};
const shouldShowCopilotPanel = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
const { is_copilot_panel_open: isCopilotPanelOpen } = uiSettings.value;
return isCaptainEnabled && isCopilotPanelOpen && !uiFlags.value.fetchingList;
});
const handleReset = () => {
messages.value = [];
selectedCopilotThreadId.value = null;
};
const sendMessage = async message => {
// Add user message
messages.value.push({
id: messages.value.length + 1,
role: 'user',
content: message,
});
isCaptainTyping.value = true;
try {
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
assistant_id: selectedAssistantId.value,
}
);
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.message,
if (selectedCopilotThreadId.value) {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
threadId: selectedCopilotThreadId.value,
message,
});
} catch (error) {
// eslint-disable-next-line
console.log(error);
} finally {
isCaptainTyping.value = false;
} else {
const response = await store.dispatch('copilotThreads/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
message,
});
selectedCopilotThreadId.value = response.id;
}
};
onMounted(() => {
store.dispatch('captainAssistants/get');
});
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
}
});
</script>
<template>
<Copilot
:messages="messages"
:support-agent="currentUser"
:is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
/>
<div
v-if="shouldShowCopilotPanel"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
>
<Copilot
:messages="messages"
:support-agent="currentUser"
:conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
/>
</div>
<template v-else />
</template>
@@ -48,12 +48,13 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-4 py-0 tab--chat-type"
class="w-full px-3 -mt-1 py-0 tab--chat-type"
@change="onTabChange"
>
<woot-tabs-item
v-for="(item, index) in items"
:key="item.key"
class="text-sm"
:index="index"
:name="item.name"
:count="item.count"
@@ -25,10 +25,7 @@ defineProps({
:username="user.name"
:status="user.availability_status"
/>
<span
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
:class="textClass"
>
<span class="my-0 truncate text-capitalize" :class="textClass">
{{ user.name }}
</span>
</div>
@@ -4,17 +4,14 @@ import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
import ConversationSidebar from './ConversationSidebar.vue';
export default {
components: {
ConversationSidebar,
ConversationHeader,
DashboardAppFrame,
EmptyState,
MessagesView,
},
props: {
inboxId: {
type: [Number, String],
@@ -34,7 +31,6 @@ export default {
default: true,
},
},
emits: ['contactPanelToggle'],
data() {
return { activeIndex: 0 };
},
@@ -86,9 +82,6 @@ export default {
}
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
onDashboardAppTabChange(index) {
this.activeIndex = index;
},
@@ -98,7 +91,7 @@ export default {
<template>
<div
class="conversation-details-wrap bg-n-background"
class="conversation-details-wrap bg-n-background relative"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
@@ -106,15 +99,12 @@ export default {
<ConversationHeader
v-if="currentChat.id"
:chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout && !isInboxView"
@contact-panel-toggle="onToggleContactPanel"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
:index="activeIndex"
class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
class="-mt-px dashboard-app--tabs border-t border-t-n-background"
@change="onDashboardAppTabChange"
>
<woot-tabs-item
@@ -130,18 +120,12 @@ export default {
v-if="currentChat.id"
:inbox-id="inboxId"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
<EmptyState
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<ConversationSidebar
v-if="showContactPanel"
:current-chat="currentChat"
@toggle-contact-panel="onToggleContactPanel"
/>
<slot />
</div>
<DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps"
@@ -243,7 +243,7 @@ export default {
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
@@ -278,7 +278,7 @@ export default {
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="40px"
size="32px"
/>
</div>
<div
@@ -1,8 +1,9 @@
<script>
import { mapGetters } from 'vuex';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue';
@@ -12,203 +13,161 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showBackButton: {
type: Boolean,
default: false,
},
});
export default {
components: {
BackButton,
InboxName,
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
NextButton,
},
mixins: [inboxMixin],
props: {
chat: {
type: Object,
default: () => {},
},
isContactPanelOpen: {
type: Boolean,
default: false,
},
showBackButton: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
emits: ['contactPanelToggle'],
setup(props, { emit }) {
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents);
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
appIntegrations: 'integrations/getAppIntegrations',
}),
chatMetadata() {
return this.chat.meta;
},
backButtonUrl() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
return conversationListPageURL({
accountId,
inboxId,
label,
teamId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
});
},
isHMACVerified() {
if (!this.isAWebWidgetInbox) {
return true;
}
return this.chatMetadata.hmac_verified;
},
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chat.meta.sender.id
);
},
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
snoozedDisplayText() {
const { snoozed_until: snoozedUntil } = this.currentChat;
if (snoozedUntil) {
return `${this.$t(
'CONVERSATION.HEADER.SNOOZED_UNTIL'
)} ${snoozedReopenTime(snoozedUntil)}`;
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
contactPanelToggleText() {
return `${
this.isContactPanelOpen
? this.$t('CONVERSATION.HEADER.CLOSE')
: this.$t('CONVERSATION.HEADER.OPEN')
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
},
hasMultipleInboxes() {
return this.$store.getters['inboxes/getInboxes'].length > 1;
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
};
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
const isFeatureEnabledonAccount = computed(
() => store.getters['accounts/isFeatureEnabledonAccount']
);
const appIntegrations = computed(
() => store.getters['integrations/getAppIntegrations']
);
const chatMetadata = computed(() => props.chat.meta);
const backButtonUrl = computed(() => {
const {
params: { inbox_id: inboxId, label, teamId },
name,
} = route;
return conversationListPageURL({
accountId,
inboxId,
label,
teamId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
});
});
const isHMACVerified = computed(() => {
if (!isAWebWidgetInbox.value) {
return true;
}
return chatMetadata.value.hmac_verified;
});
const currentContact = computed(() =>
store.getters['contacts/getContact'](props.chat.meta.sender.id)
);
const isSnoozed = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
);
const snoozedDisplayText = computed(() => {
const { snoozed_until: snoozedUntil } = currentChat.value;
if (snoozedUntil) {
return `${t('CONVERSATION.HEADER.SNOOZED_UNTIL')} ${snoozedReopenTime(snoozedUntil)}`;
}
return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
});
const inbox = computed(() => {
const { inbox_id: inboxId } = props.chat;
return store.getters['inboxes/getInbox'](inboxId);
});
const hasMultipleInboxes = computed(
() => store.getters['inboxes/getInboxes'].length > 1
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const isLinearIntegrationEnabled = computed(() =>
appIntegrations.value.find(
integration => integration.id === 'linear' && !!integration.hooks.length
)
);
const isLinearFeatureEnabled = computed(() =>
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR)
);
</script>
<template>
<div
class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
ref="conversationHeader"
class="flex flex-col items-center justify-center flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
>
<div
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0"
>
<div class="flex items-center justify-start max-w-full min-w-0 w-fit">
<BackButton
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Thumbnail
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
>
<div
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
<BackButton
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Thumbnail
:src="currentContact.thumbnail"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
>
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
<NextButton link slate @click.prevent="$emit('contactPanelToggle')">
<span
class="text-base font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
</NextButton>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
{{ currentContact.name }}
</span>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
<NextButton
link
xs
blue
:label="contactPanelToggleText"
@click="$emit('contactPanelToggle')"
/>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
</div>
</div>
<div
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
<div
class="flex flex-row items-center justify-start xl:justify-end flex-grow gap-2 w-full xl:w-auto mt-3 header-actions-wrap xl:mt-0"
>
<SLACardLabel
v-if="hasSlaPolicyId"
:chat="chat"
show-extended-info
:parent-width="width"
class="hidden lg:flex"
/>
<Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
:parent-width="width"
class="hidden lg:flex"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
</template>
<style lang="scss" scoped>
.conversation--header--actions {
::v-deep .inbox--name {
@apply m-0;
}
}
</style>
@@ -1,81 +1,36 @@
<script setup>
import { computed, ref } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import { computed } from 'vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
defineProps({
currentChat: {
required: true,
type: Object,
},
});
const emit = defineEmits(['toggleContactPanel']);
const { uiSettings } = useUISettings();
const { t } = useI18n();
const activeTab = computed(() => {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
const channelType = computed(() => props.currentChat?.meta?.channel || '');
const CONTACT_TABS_OPTIONS = [
{ key: 'CONTACT', value: 'contact' },
{ key: 'COPILOT', value: 'copilot' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
value: tab.value,
}));
if (isContactSidebarOpen) {
return 0;
}
return null;
});
const activeTab = ref(0);
const toggleContactPanel = () => {
emit('toggleContactPanel');
};
const handleTabChange = selectedTab => {
activeTab.value = tabs.value.findIndex(
tabItem => tabItem.value === selectedTab.value
);
};
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
</script>
<template>
<div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
>
<div v-if="showCopilotTab" class="p-2">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTab"
class="w-full [&>button]:w-full"
@tab-changed="handleTabChange"
/>
</div>
<div class="flex flex-1 overflow-auto">
<ContactPanel
v-if="!activeTab"
v-show="activeTab === 0"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
:on-toggle="toggleContactPanel"
/>
<CopilotContainer
v-else-if="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
class="flex-1"
/>
</div>
</div>
@@ -38,8 +38,6 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Message,
@@ -47,20 +45,8 @@ export default {
ReplyBox,
Banner,
ConversationLabelSuggestion,
NextButton,
},
mixins: [inboxMixin],
props: {
isContactPanelOpen: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
emits: ['contactPanelToggle'],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
@@ -203,12 +189,6 @@ export default {
isATweet() {
return this.conversationType === 'tweet';
},
isRightOrLeftIcon() {
if (this.isContactPanelOpen) {
return 'arrow-chevron-right';
}
return 'arrow-chevron-left';
},
getLastSeenAt() {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt;
@@ -444,9 +424,6 @@ export default {
relevantMessages
);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
@@ -530,19 +507,6 @@ export default {
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div class="flex justify-end">
<NextButton
faded
xs
slate
class="!rounded-r-none rtl:rotate-180 !rounded-2xl !fixed z-10"
:icon="
isContactPanelOpen ? 'i-ph-caret-right-fill' : 'i-ph-caret-left-fill'
"
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
@click="onToggleContactPanel"
/>
</div>
<NextMessageList
v-if="showNextBubbles"
ref="conversationPanelRef"
@@ -1,10 +1,14 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, onUnmounted } from 'vue';
import { useToggle } from '@vueuse/core';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt';
import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import {
CMD_MUTE_CONVERSATION,
@@ -12,97 +16,111 @@ import {
CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
export default {
components: {
EmailTranscriptModal,
ResolveAction,
ButtonV4,
},
data() {
return {
showEmailActionsModal: false,
};
},
computed: {
...mapGetters({ currentChat: 'getSelectedChat' }),
},
mounted() {
emitter.on(CMD_MUTE_CONVERSATION, this.mute);
emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
unmounted() {
emitter.off(CMD_MUTE_CONVERSATION, this.mute);
emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
methods: {
mute() {
this.$store.dispatch('muteConversation', this.currentChat.id);
useAlert(this.$t('CONTACT_PANEL.MUTED_SUCCESS'));
},
unmute() {
this.$store.dispatch('unmuteConversation', this.currentChat.id);
useAlert(this.$t('CONTACT_PANEL.UNMUTED_SUCCESS'));
},
toggleEmailActionsModal() {
this.showEmailActionsModal = !this.showEmailActionsModal;
},
},
// No props needed as we're getting currentChat from the store directly
const store = useStore();
const { t } = useI18n();
const [showEmailActionsModal, toggleEmailModal] = useToggle(false);
const [showActionsDropdown, toggleDropdown] = useToggle(false);
const currentChat = computed(() => store.getters.getSelectedChat);
const actionMenuItems = computed(() => {
const items = [];
if (!currentChat.value.muted) {
items.push({
icon: 'i-lucide-volume-off',
label: t('CONTACT_PANEL.MUTE_CONTACT'),
action: 'mute',
value: 'mute',
});
} else {
items.push({
icon: 'i-lucide-volume-1',
label: t('CONTACT_PANEL.UNMUTE_CONTACT'),
action: 'unmute',
value: 'unmute',
});
}
items.push({
icon: 'i-lucide-share',
label: t('CONTACT_PANEL.SEND_TRANSCRIPT'),
action: 'send_transcript',
value: 'send_transcript',
});
return items;
});
const handleActionClick = ({ action }) => {
toggleDropdown(false);
if (action === 'mute') {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
} else if (action === 'unmute') {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
} else if (action === 'send_transcript') {
toggleEmailModal();
}
};
// These functions are needed for the event listeners
const mute = () => {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
};
const unmute = () => {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
};
emitter.on(CMD_MUTE_CONVERSATION, mute);
emitter.on(CMD_UNMUTE_CONVERSATION, unmute);
emitter.on(CMD_SEND_TRANSCRIPT, toggleEmailModal);
onUnmounted(() => {
emitter.off(CMD_MUTE_CONVERSATION, mute);
emitter.off(CMD_UNMUTE_CONVERSATION, unmute);
emitter.off(CMD_SEND_TRANSCRIPT, toggleEmailModal);
});
</script>
<template>
<div class="relative flex items-center gap-2 actions--container">
<ButtonV4
v-if="!currentChat.muted"
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-off"
@click="mute"
/>
<ButtonV4
v-else
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-1"
@click="unmute"
/>
<ButtonV4
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-share"
@click="toggleEmailActionsModal"
/>
<ResolveAction
:conversation-id="currentChat.id"
:status="currentChat.status"
/>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<ButtonV4
v-tooltip="$t('CONVERSATION.HEADER.MORE_ACTIONS')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-more-vertical"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="actionMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleActionClick"
/>
</div>
<EmailTranscriptModal
v-if="showEmailActionsModal"
:show="showEmailActionsModal"
:current-chat="currentChat"
@cancel="toggleEmailActionsModal"
@cancel="toggleEmailModal"
/>
</div>
</template>
<style scoped lang="scss">
.more--button {
@apply items-center flex ml-2 rtl:ml-0 rtl:mr-2;
}
.dropdown-pane {
@apply -right-2 top-12;
}
.icon {
@apply mr-1 rtl:mr-0 rtl:ml-1 min-w-[1rem];
}
</style>
@@ -1,132 +1,115 @@
<script>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
parentWidth: {
type: Number,
default: 1000,
},
});
const REFRESH_INTERVAL = 60000;
const { t } = useI18n();
export default {
components: {
SLAPopoverCard,
},
props: {
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
},
data() {
return {
timer: null,
showSlaPopover: false,
slaStatus: {
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
},
};
},
computed: {
slaPolicyId() {
return this.chat?.sla_policy_id;
},
appliedSLA() {
return this.chat?.applied_sla;
},
slaEvents() {
return this.chat?.sla_events;
},
hasSlaThreshold() {
return this.slaStatus?.threshold;
},
isSlaMissed() {
return this.slaStatus?.isSlaMissed;
},
slaTextStyles() {
return this.isSlaMissed ? 'text-n-ruby-11' : 'text-n-amber-11';
},
slaStatusText() {
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
},
showSlaPopoverCard() {
return (
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
);
},
},
watch: {
chat() {
this.updateSlaStatus();
},
},
mounted() {
this.updateSlaStatus();
this.createTimer();
},
unmounted() {
if (this.timer) {
clearTimeout(this.timer);
}
},
methods: {
createTimer() {
this.timer = setTimeout(() => {
this.updateSlaStatus();
this.createTimer();
}, REFRESH_INTERVAL);
},
updateSlaStatus() {
this.slaStatus = evaluateSLAStatus({
appliedSla: this.appliedSLA,
chat: this.chat,
});
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
this.showSlaPopover = true;
},
closeSlaPopover() {
this.showSlaPopover = false;
},
},
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaTextStyles = computed(() =>
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
);
const slaStatusText = computed(() => {
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
});
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
);
const groupClass = computed(() => {
return props.showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong';
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
watch(
() => props.chat,
() => {
updateSlaStatus();
}
);
const slaPopoverClass = computed(() => {
return props.showExtendedInfo
? 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
: '';
});
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="hasSlaThreshold"
class="relative flex items-center cursor-pointer min-w-fit"
:class="
showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong'
"
class="relative flex items-center cursor-pointer min-w-fit group"
:class="groupClass"
>
<div
v-on-clickaway="closeSlaPopover"
class="flex items-center w-full truncate"
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
@mouseover="openSlaPopover()"
class="flex items-center w-full truncate px-1.5"
:class="showExtendedInfo ? '' : 'gap-1'"
>
<div
class="flex items-center gap-1"
:class="
showExtendedInfo &&
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
"
>
<div class="flex items-center gap-1" :class="slaPopoverClass">
<fluent-icon
size="14"
size="12"
:icon="slaStatus.icon"
type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
@@ -134,7 +117,7 @@ export default {
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo"
v-if="showExtendedInfo && parentWidth > 650"
class="text-xs font-medium"
:class="slaTextStyles"
>
@@ -151,7 +134,7 @@ export default {
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="right-0 top-7"
class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex"
/>
</div>
</template>
@@ -16,6 +16,10 @@ const props = defineProps({
type: [Number, String],
required: true,
},
parentWidth: {
type: Number,
default: 10000,
},
});
defineOptions({
@@ -73,6 +77,14 @@ const unlinkIssue = async linkId => {
}
};
const shouldShowIssueIdentifier = computed(() => {
if (!linkedIssue.value) {
return false;
}
return props.parentWidth > 600;
});
const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true;
@@ -119,7 +131,10 @@ onMounted(() => {
class="text-[#5E6AD2] flex-shrink-0"
view-box="0 0 19 19"
/>
<span v-if="linkedIssue" class="text-xs font-medium text-n-slate-11">
<span
v-if="shouldShowIssueIdentifier"
class="text-xs font-medium text-n-slate-11"
>
{{ linkedIssue.issue.identifier }}
</span>
</Button>
@@ -127,7 +142,7 @@ onMounted(() => {
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute right-0 top-[36px] invisible group-hover:visible"
class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<woot-modal
@@ -1,110 +0,0 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import MoreActions from '../MoreActions.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
}));
const mockDirective = {
mounted: () => {},
};
import { emitter } from 'shared/helpers/mitt';
describe('MoveActions', () => {
let currentChat = { id: 8, muted: false };
let store = null;
let muteConversation = null;
let unmuteConversation = null;
beforeEach(() => {
muteConversation = vi.fn(() => Promise.resolve());
unmuteConversation = vi.fn(() => Promise.resolve());
store = createStore({
state: {
authenticated: true,
currentChat,
},
getters: {
getSelectedChat: () => currentChat,
},
modules: {
conversations: {
namespaced: false,
actions: { muteConversation, unmuteConversation },
},
},
});
});
const createWrapper = () =>
mount(MoreActions, {
global: {
plugins: [store],
components: {
'fluent-icon': FluentIcon,
},
directives: {
'on-clickaway': mockDirective,
},
},
});
describe('muting discussion', () => {
it('triggers "muteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(muteConversation).toHaveBeenCalledTimes(1);
expect(muteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message:
'This contact is blocked successfully. You will not be notified of any future conversations.',
action: null,
});
});
});
describe('unmuting discussion', () => {
beforeEach(() => {
currentChat.muted = true;
});
it('triggers "unmuteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(unmuteConversation).toHaveBeenCalledTimes(1);
expect(unmuteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message: 'This contact is unblocked successfully.',
action: null,
});
});
});
});
@@ -43,7 +43,7 @@ describe('useFontSize', () => {
it('returns fontSizeOptions with correct structure', () => {
const { fontSizeOptions } = useFontSize();
expect(fontSizeOptions).toHaveLength(6);
expect(fontSizeOptions).toHaveLength(5);
expect(fontSizeOptions[0]).toHaveProperty('value');
expect(fontSizeOptions[0]).toHaveProperty('label');
@@ -59,12 +59,6 @@ describe('useFontSize', () => {
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
});
expect(fontSizeOptions.find(option => option.value === '22px')).toEqual({
value: '22px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE',
});
});
it('returns currentFontSize from UI settings', () => {
@@ -84,9 +78,6 @@ describe('useFontSize', () => {
applyFontSize('14px');
expect(document.documentElement.style.fontSize).toBe('14px');
applyFontSize('22px');
expect(document.documentElement.style.fontSize).toBe('22px');
applyFontSize('16px');
expect(document.documentElement.style.fontSize).toBe('16px');
});
@@ -145,8 +136,6 @@ describe('useFontSize', () => {
'Smaller',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
'Default',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE':
'Extra Large',
};
return translations[key] || key;
});
@@ -160,9 +149,6 @@ describe('useFontSize', () => {
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
'Default'
);
expect(fontSizeOptions.find(option => option.value === '22px').label).toBe(
'Extra Large'
);
// Verify translation function was called with correct keys
expect(mockTranslate).toHaveBeenCalledWith(
@@ -19,7 +19,6 @@ const FONT_SIZE_OPTIONS = {
DEFAULT: '16px',
LARGE: '18px',
LARGER: '20px',
EXTRA_LARGE: '22px',
};
/**
@@ -129,6 +129,7 @@ export function usePolicy() {
return {
checkPermissions,
shouldShowPaywall,
isFeatureFlagEnabled,
shouldShow,
};
}
@@ -62,7 +62,9 @@
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely",
"COPY_SUCCESSFUL": "Access token copied to clipboard"
"COPY_SUCCESSFUL": "Access token copied to clipboard",
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
},
"FORM": {
"AVATAR": {
@@ -70,6 +70,7 @@
"RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen",
"OPEN_ACTION": "Open",
"MORE_ACTIONS": "More actions",
"OPEN": "More",
"CLOSE": "Close",
"DETAILS": "details",
@@ -4,6 +4,7 @@
"PHONE_INPUT": {
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
}
},
"CLOSE": "Close"
}
}
@@ -328,6 +328,9 @@
"HEADER_KNOW_MORE": "Know more",
"COPILOT": {
"TITLE": "Copilot",
"TRY_THESE_PROMPTS": "Try these prompts",
"PANEL_TITLE": "Get started with Copilot",
"KICK_OFF_MESSAGE": "Need a quick summary, want to check past conversations, or draft a better reply? Copilots here to speed things up.",
"SEND_MESSAGE": "Send message...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking",
@@ -348,6 +351,14 @@
"RATE": {
"LABEL": "Rate this conversation",
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
},
"HIGH_PRIORITY": {
"LABEL": "High priority conversations",
"CONTENT": "Give me a summary of all high priority open conversations. Include the conversation ID, customer name (if available), last message content, and assigned agent. Group by status if relevant."
},
"LIST_CONTACTS": {
"LABEL": "List contacts",
"CONTENT": "Show me the list of top 10 contacts. Include name, email or phone number (if available), last seen time, tags (if any)."
}
}
},
@@ -381,6 +392,7 @@
},
"ASSISTANTS": {
"HEADER": "Assistants",
"NO_ASSISTANTS_AVAILABLE": "There are no assistants available in your account.",
"ADD_NEW": "Create a new assistant",
"DELETE": {
"TITLE": "Are you sure to delete the assistant?",
@@ -76,7 +76,12 @@
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
"COPY": "Copy"
"COPY": "Copy",
"RESET": "Reset",
"CONFIRM_RESET": "Are you sure?",
"CONFIRM_HINT": "Click again to confirm",
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Alerts",
@@ -10,10 +10,8 @@ import {
CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import Policy from 'dashboard/components/policy.vue';
@@ -39,8 +37,6 @@ const pages = ref({
articles: 1,
});
const currentUser = useMapGetter('getCurrentUser');
const currentAccountId = useMapGetter('getCurrentAccountId');
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
const conversationRecords = useMapGetter(
'conversationSearch/getConversationRecords'
@@ -83,9 +79,7 @@ const filterConversations = filterByTab('conversations');
const filterMessages = filterByTab('messages');
const filterArticles = filterByTab('articles');
const userPermissions = computed(() =>
getUserPermissions(currentUser.value, currentAccountId.value)
);
const { shouldShow, isFeatureFlagEnabled } = usePolicy();
const TABS_CONFIG = {
all: {
@@ -111,47 +105,67 @@ const TABS_CONFIG = {
},
articles: {
permissions: [...ROLES, PORTAL_PERMISSIONS],
featureFlag: FEATURE_FLAGS.HELP_CENTER,
count: () => mappedArticles.value.length,
},
};
const tabs = computed(() => {
const configs = Object.entries(TABS_CONFIG).map(([key, config]) => ({
key,
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
count: config.count(),
showBadge: key !== 'all',
permissions: config.permissions,
}));
return filterItemsByPermission(
configs,
userPermissions.value,
item => item.permissions
);
return Object.entries(TABS_CONFIG)
.map(([key, config]) => ({
key,
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
count: config.count(),
showBadge: key !== 'all',
permissions: config.permissions,
featureFlag: config.featureFlag,
}))
.filter(config => {
// why the double check, glad you asked.
// Some features are marked as premium features, that means
// the feature will be visible, but a Paywall will be shown instead
// this works for pages and routes, but fails for UI elements like search here
// so we explicitly check if the feature is enabled
return (
shouldShow(config.featureFlag, config.permissions, null) &&
isFeatureFlagEnabled(config.featureFlag)
);
});
});
const totalSearchResultsCount = computed(() => {
const permissionCounts = {
contacts: {
const permissionCounts = [
{
permissions: [...ROLES, CONTACT_PERMISSIONS],
count: () => contacts.value.length,
},
conversations: {
{
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => conversations.value.length + messages.value.length,
},
articles: {
{
permissions: [...ROLES, PORTAL_PERMISSIONS],
featureFlag: FEATURE_FLAGS.HELP_CENTER,
count: () => articles.value.length,
},
};
return filterItemsByPermission(
permissionCounts,
userPermissions.value,
item => item.permissions,
(_, item) => item.count
).reduce((total, count) => total + count(), 0);
];
return permissionCounts
.filter(config => {
// why the double check, glad you asked.
// Some features are marked as premium features, that means
// the feature will be visible, but a Paywall will be shown instead
// this works for pages and routes, but fails for UI elements like search here
// so we explicitly check if the feature is enabled
return (
shouldShow(config.featureFlag, config.permissions, null) &&
isFeatureFlagEnabled(config.featureFlag)
);
})
.map(config => {
return config.count();
})
.reduce((sum, count) => sum + count, 0);
});
const activeTabIndex = computed(() => {
@@ -355,7 +369,9 @@ onUnmounted(() => {
</Policy>
<Policy
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.HELP_CENTER)"
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
:feature-flag="FEATURE_FLAGS.HELP_CENTER"
class="flex flex-col justify-center"
>
<SearchResultArticlesList
@@ -25,6 +25,8 @@ const Sidebar = defineAsyncComponent(
() => import('../../components/layout/Sidebar.vue')
);
import { emitter } from 'shared/helpers/mitt';
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
export default {
components: {
@@ -37,6 +39,8 @@ export default {
AddLabelModal,
NotificationPanel,
UpgradePage,
CopilotLauncher,
CopilotContainer,
},
setup() {
const upgradePageRef = ref(null);
@@ -219,6 +223,9 @@ export default {
<template v-if="!showUpgradePage">
<router-view />
<CommandBar />
<CopilotLauncher />
<CopilotContainer />
<NotificationPanel
v-if="isNotificationPanel"
@close="closeNotificationPanel"
@@ -16,7 +16,7 @@ onMounted(() => {
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background px-6"
>
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
@@ -11,14 +11,14 @@ import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from '../../../components/widgets/conversation/ShopifyOrdersList.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
const props = defineProps({
conversationId: {
@@ -29,10 +29,6 @@ const props = defineProps({
type: Number,
default: undefined,
},
onToggle: {
type: Function,
default: () => {},
},
});
const {
@@ -89,8 +85,6 @@ watch(conversationId, (newConversationId, prevConversationId) => {
watch(contactId, getContactDetails);
const onPanelToggle = props.onToggle;
const onDragEnd = () => {
dragging.value = false;
updateUISettings({
@@ -98,6 +92,13 @@ const onDragEnd = () => {
});
};
const closeContactPanel = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
};
onMounted(() => {
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
getContactDetails();
@@ -107,11 +108,11 @@ onMounted(() => {
<template>
<div class="w-full">
<ContactInfo
:contact="contact"
:channel-type="channelType"
@toggle-panel="onPanelToggle"
<SidebarActionsHeader
:title="$t('CONVERSATION.SIDEBAR.CONTACT')"
@close="closeContactPanel"
/>
<ContactInfo :contact="contact" :channel-type="channelType" />
<div class="list-group pb-8">
<Draggable
:list="conversationSidebarItems"
@@ -10,6 +10,8 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
import { emitter } from 'shared/helpers/mitt';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
export default {
components: {
@@ -17,6 +19,8 @@ export default {
ConversationBox,
PopOverSearch,
CmdBarConversationSnooze,
SidepanelSwitch,
ConversationSidebar,
},
beforeRouteLeave(to, from, next) {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
@@ -87,13 +91,14 @@ export default {
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
shouldShowSidebar() {
if (!this.currentChat.id) {
return false;
}
return false;
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
return isContactSidebarOpen;
},
showPopOverSearch() {
return !this.isFeatureEnabledonAccount(
@@ -189,11 +194,6 @@ export default {
this.$store.dispatch('clearSelectedState');
}
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
onSearch() {
this.showSearchModal = true;
},
@@ -225,10 +225,11 @@ export default {
<ConversationBox
v-if="showMessageView"
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel"
/>
>
<SidepanelSwitch v-if="currentChat.id" />
</ConversationBox>
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
<CmdBarConversationSnooze />
</section>
</template>
@@ -183,7 +183,6 @@ export default {
</div>
<div v-else class="flex flex-col w-full h-full">
<InboxItemHeader
class="flex-1"
:total-length="totalNotificationCount"
:current-index="activeNotificationIndex"
:active-notification="activeNotification"
@@ -109,7 +109,7 @@ export default {
<template>
<div
class="flex items-center justify-between w-full gap-2 py-2 border-b ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
class="flex items-center justify-between w-full gap-2 border-b ltr:pl-4 rtl:pl-2 h-12 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
>
<div class="flex items-center gap-4">
<BackButton
@@ -228,7 +228,20 @@ const initializeForm = () => {
const onCopyToken = async value => {
await copyTextToClipboard(value);
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL'));
};
const onResetToken = async () => {
const response = await store.dispatch(
'agentBots/resetAccessToken',
props.selectedBot.id
);
if (response) {
accessToken.value = response.access_token;
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_SUCCESS'));
} else {
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_ERROR'));
}
};
const closeModal = () => {
@@ -312,7 +325,18 @@ defineExpose({ dialogRef });
>
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
</label>
<AccessToken :value="accessToken" @on-copy="onCopyToken" />
<AccessToken
v-if="type === MODAL_TYPES.EDIT"
:value="accessToken"
@on-copy="onCopyToken"
@on-reset="onResetToken"
/>
<AccessToken
v-else
:value="accessToken"
:show-reset-button="false"
@on-copy="onCopyToken"
/>
</div>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
@@ -1,14 +1,17 @@
<script setup>
import { ref, computed } from 'vue';
import FormButton from 'v3/components/Form/Button.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
const props = defineProps({
value: {
type: String,
default: '',
},
value: { type: String, default: '' },
showResetButton: { type: Boolean, default: true },
});
const emit = defineEmits(['onCopy']);
const emit = defineEmits(['onCopy', 'onReset']);
const inputType = ref('password');
const toggleMasked = () => {
inputType.value = inputType.value === 'password' ? 'text' : 'password';
};
@@ -20,6 +23,10 @@ const maskIcon = computed(() => {
const onClick = () => {
emit('onCopy', props.value);
};
const onReset = () => {
emit('onReset');
};
</script>
<template>
@@ -38,7 +45,7 @@ const onClick = () => {
>
<template #masked>
<button
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5"
type="button"
@click="toggleMasked"
>
@@ -46,15 +53,28 @@ const onClick = () => {
</button>
</template>
</woot-input>
<FormButton
type="button"
size="large"
icon="text-copy"
variant="outline"
color-scheme="secondary"
@click="onClick"
>
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }}
</FormButton>
<div class="flex flex-row gap-2">
<NextButton
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY')"
slate
outline
type="button"
icon="i-lucide-copy"
class="rounded-xl"
@click="onClick"
/>
<ConfirmButton
v-if="showResetButton"
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET')"
:confirm-label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CONFIRM_RESET')"
:confirm-hint="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CONFIRM_HINT')"
color="slate"
confirm-color="ruby"
variant="outline"
icon="i-lucide-key-round"
class="rounded-xl"
@click="onReset"
/>
</div>
</div>
</template>
@@ -181,6 +181,14 @@ export default {
await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
async resetAccessToken() {
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
} else {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_ERROR'));
}
},
},
};
</script>
@@ -237,7 +245,12 @@ export default {
<button
v-for="hotKey in hotKeys"
:key="hotKey.key"
class="px-0 reset-base w-full sm:flex-1"
class="px-0 reset-base w-full sm:flex-1 rounded-xl outline-1 outline"
:class="
isEditorHotKeyEnabled(hotKey.key)
? 'outline-n-brand/30'
: 'outline-n-weak'
"
>
<HotKeyCard
:key="hotKey.title"
@@ -281,7 +294,11 @@ export default {
)
"
>
<AccessToken :value="currentUser.access_token" @on-copy="onCopyToken" />
<AccessToken
:value="currentUser.access_token"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
</FormSection>
</div>
</template>
@@ -1,4 +1,6 @@
<script setup>
import { computed } from 'vue';
const { row } = defineProps({
row: {
type: Object,
@@ -6,10 +8,10 @@ const { row } = defineProps({
},
});
const routerParams = {
const routerParams = computed(() => ({
name: 'inbox_conversation',
params: { conversation_id: row.original.conversationId },
};
}));
</script>
<template>
@@ -47,14 +47,17 @@ const tableData = computed(() => {
}));
});
const defaulSpanRender = cellProps =>
h(
const defaultSpanRender = cellProps => {
const value = cellProps.getValue() || '---';
return h(
'span',
{
class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
class: 'line-clamp-5 break-words max-w-full text-n-slate-12',
title: value,
},
cellProps.getValue() ? cellProps.getValue() : '---'
value
);
};
const columnHelper = createColumnHelper();
@@ -65,7 +68,10 @@ const columns = [
cell: cellProps => {
const { contact } = cellProps.row.original;
if (contact) {
return h(UserAvatarWithName, { user: contact });
return h(UserAvatarWithName, {
user: contact,
class: 'max-w-[200px] overflow-hidden',
});
}
return '--';
},
@@ -76,7 +82,10 @@ const columns = [
cell: cellProps => {
const { assignedAgent } = cellProps.row.original;
if (assignedAgent) {
return h(UserAvatarWithName, { user: assignedAgent });
return h(UserAvatarWithName, {
user: assignedAgent,
class: 'max-w-[200px] overflow-hidden',
});
}
return '--';
},
@@ -105,7 +114,7 @@ const columns = [
columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
width: 400,
cell: defaulSpanRender,
cell: defaultSpanRender,
}),
columnHelper.accessor('conversationId', {
header: '',
@@ -1,7 +1,5 @@
<template>
<div
class="reports--wrapper overflow-auto bg-n-background w-full px-8 xl:px-0"
>
<div class="reports--wrapper overflow-auto bg-n-background w-full px-6">
<div class="max-w-[60rem] mx-auto pb-12">
<router-view />
</div>
@@ -6,9 +6,9 @@ export default createStore({
API: CopilotMessagesAPI,
getters: {
getMessagesByThreadId: state => copilotThreadId => {
return state.records.filter(
record => record.copilot_thread?.id === Number(copilotThreadId)
);
return state.records
.filter(record => record.copilot_thread?.id === Number(copilotThreadId))
.sort((a, b) => a.id - b.id);
},
},
actions: mutationTypes => ({
@@ -172,6 +172,17 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false });
}
},
resetAccessToken: async ({ commit }, botId) => {
try {
const response = await AgentBotsAPI.resetAccessToken(botId);
commit(types.EDIT_AGENT_BOT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
}
},
};
export const mutations = {
@@ -213,6 +213,16 @@ export const actions = {
}
},
resetAccessToken: async ({ commit }) => {
try {
const response = await authAPI.resetAccessToken();
commit(types.SET_CURRENT_USER, response.data);
return true;
} catch (error) {
return false;
}
},
resendConfirmation: async () => {
try {
await authAPI.resendConfirmation();
@@ -18,13 +18,34 @@ const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
},
getFilteredConversations: ({
allConversations,
chatSortFilter,
appliedFilters,
}) => {
getFilteredConversations: (
{ allConversations, chatSortFilter, appliedFilters },
_,
__,
rootGetters
) => {
const currentUser = rootGetters.getCurrentUser;
const currentUserId = rootGetters.getCurrentUser.id;
const currentAccountId = rootGetters.getCurrentAccountId;
const permissions = getUserPermissions(currentUser, currentAccountId);
const userRole = getUserRole(currentUser, currentAccountId);
return allConversations
.filter(conversation => matchesFilters(conversation, appliedFilters))
.filter(conversation => {
const matchesFilterResult = matchesFilters(
conversation,
appliedFilters
);
const allowedForRole = applyRoleFilter(
conversation,
userRole,
permissions,
currentUserId
);
return matchesFilterResult && allowedForRole;
})
.sort((a, b) => sortComparator(a, b, chatSortFilter));
},
getSelectedChat: ({ selectedChatId, allConversations }) => {
@@ -170,4 +170,21 @@ describe('#actions', () => {
]);
});
});
describe('#resetAccessToken', () => {
it('sends correct actions if API is success', async () => {
const mockResponse = {
data: { ...agentBotRecords[0], access_token: 'new_token_123' },
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.resetAccessToken(
{ commit },
agentBotRecords[0].id
);
expect(commit.mock.calls).toEqual([
[types.EDIT_AGENT_BOT, mockResponse.data],
]);
expect(result).toBe(mockResponse.data);
});
});
});
@@ -228,4 +228,20 @@ describe('#actions', () => {
);
});
});
describe('#resetAccessToken', () => {
it('sends correct actions if API is success', async () => {
const mockResponse = {
data: { id: 1, name: 'John', access_token: 'new_token_123' },
headers: { expiry: 581842904 },
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.resetAccessToken({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER, mockResponse.data],
]);
expect(result).toBe(true);
});
});
});
@@ -325,4 +325,308 @@ describe('#getters', () => {
});
});
});
describe('#getFilteredConversations', () => {
const mockConversations = [
{
id: 1,
status: 'open',
meta: { assignee: { id: 1 } },
last_activity_at: 1000,
},
{
id: 2,
status: 'open',
meta: {},
last_activity_at: 2000,
},
{
id: 3,
status: 'resolved',
meta: { assignee: { id: 2 } },
last_activity_at: 3000,
},
];
const mockRootGetters = {
getCurrentUser: {
id: 1,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
getCurrentAccountId: 1,
};
it('filters conversations based on role permissions for administrator', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'administrator', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations based on role permissions for agent', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_unassigned_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_unassigned_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should include conversation assigned to user (id: 1) and unassigned conversation
expect(result).toEqual([mockConversations[1], mockConversations[0]]);
});
it('filters conversations for custom role with conversation_participating_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('filters conversations for custom role with no permissions', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: [],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should return empty array as user has no permissions
expect(result).toEqual([]);
});
it('applies filters and role permissions together', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'and',
},
],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include open conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('returns empty array when no conversations match filters', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['pending'],
query_operator: 'and',
},
],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([]);
});
it('sorts filtered conversations according to chatSortFilter', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_asc',
appliedFilters: [],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([
mockConversations[0],
mockConversations[1],
mockConversations[2],
]);
});
});
});
+1 -46
View File
@@ -2,6 +2,7 @@ import { createApp } from 'vue';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import { directive as onClickaway } from 'vue3-click-away';
import { isSameHost } from '@chatwoot/utils';
import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
@@ -25,52 +26,6 @@ export const getHeadingsfromTheArticle = () => {
return rows;
};
/**
* Converts various input formats to URL objects.
* Handles URL objects, domain strings, relative paths, and full URLs.
* @param {string|URL} input - Input to convert to URL object
* @returns {URL|null} URL object or null if input is invalid
*/
const toURL = input => {
if (!input) return null;
if (input instanceof URL) return input;
if (
typeof input === 'string' &&
!input.includes('://') &&
!input.startsWith('/')
) {
return new URL(`https://${input}`);
}
if (typeof input === 'string' && input.startsWith('/')) {
return new URL(input, window.location.origin);
}
return new URL(input);
};
/**
* Determines if two URLs belong to the same host by comparing their normalized URL objects.
* Handles various input formats including URL objects, domain strings, relative paths, and full URLs.
* Returns false if either URL cannot be parsed or normalized.
* @param {string|URL} url1 - First URL to compare
* @param {string|URL} url2 - Second URL to compare
* @returns {boolean} True if both URLs have the same host, false otherwise
*/
const isSameHost = (url1, url2) => {
try {
const urlObj1 = toURL(url1);
const urlObj2 = toURL(url2);
if (!urlObj1 || !urlObj2) return false;
return urlObj1.hostname === urlObj2.hostname;
} catch (error) {
return false;
}
};
export const openExternalLinksInNewTab = () => {
const { customDomain, hostURL } = window.portalConfig;
const isOnArticlePage =
+3 -3
View File
@@ -64,11 +64,11 @@ export default {
:selected="modelValue"
:name="name"
:class="{
'text-ash-400': !modelValue,
'text-ash-900': modelValue,
'text-n-slate-9': !modelValue,
'text-n-slate-12': modelValue,
'pl-9': icon,
}"
class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm outline-none appearance-none rounded-xl select-caret ring-ash-200 ring-1 ring-inset placeholder:text-ash-900 focus:ring-2 focus:ring-inset focus:ring-primary-500 text-sm leading-6"
class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm appearance-none rounded-xl select-caret leading-6"
@input="onInput"
>
<option value="" disabled selected class="hidden">
+13 -7
View File
@@ -76,18 +76,24 @@ class Article < ApplicationRecord
scope :order_by_views, -> { reorder(views: :desc) }
# TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS
# - the A, B and C are for weightage. See: https://github.com/Casecommons/pg_search#weighting
# - the normalization is for ensuring the long articles that mention the search term too many times are not ranked higher.
# it divides rank by log(document_length) to prevent longer articles from ranking higher just due to sizeSee: https://github.com/Casecommons/pg_search#normalization
# - the ranking is to ensure that articles with higher weightage are ranked higher
pg_search_scope(
:text_search,
against: %i[
title
description
content
],
against: {
title: 'A',
description: 'B',
content: 'C'
},
using: {
tsearch: {
prefix: true
prefix: true,
normalization: 2
}
}
},
ranked_by: ':tsearch'
)
def self.search(params)
@@ -5,7 +5,6 @@ module ActivityMessageHandler
include LabelActivityMessageHandler
include SlaActivityMessageHandler
include TeamActivityMessageHandler
include CsatActivityMessageHandler
private
@@ -1,8 +0,0 @@
module CsatActivityMessageHandler
extend ActiveSupport::Concern
def create_csat_not_sent_activity_message
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
end
+4
View File
@@ -22,4 +22,8 @@ class AgentBotPolicy < ApplicationPolicy
def avatar?
@account_user.administrator?
end
def reset_access_token?
@account_user.administrator?
end
end
@@ -11,3 +11,5 @@ class CsatSurveyResponsePolicy < ApplicationPolicy
@account_user.administrator?
end
end
CsatSurveyResponsePolicy.prepend_mod_with('CsatSurveyResponsePolicy')
@@ -17,7 +17,7 @@ class MessageTemplates::HookExecutionService
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
handle_csat_survey
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey?
end
def should_send_out_of_office_message?
@@ -65,26 +65,13 @@ class MessageTemplates::HookExecutionService
true
end
def handle_csat_survey
def should_send_csat_survey?
return unless csat_enabled_conversation?
# only send CSAT once in a conversation
return if csat_already_sent?
return if conversation.messages.where(content_type: :input_csat).present?
# Only send CSAT if agent can still reply by checking the messaging window restriction
# https://www.chatwoot.com/docs/self-hosted/supported-features#outgoing-message-restriction
if within_messaging_window?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
else
conversation.create_csat_not_sent_activity_message
end
end
def csat_already_sent?
conversation.messages.where(content_type: :input_csat).present?
end
def within_messaging_window?
conversation.can_reply?
true
end
end
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')
-1
View File
@@ -96,7 +96,6 @@ class SearchService
def filter_articles
@articles = current_account.articles
.text_search(search_query)
.reorder('updated_at DESC')
.page(params[:page])
.per(15)
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/agent_bot', formats: [:json], resource: AgentBotPresenter.new(@agent_bot)
@@ -0,0 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
+24
View File
@@ -73,6 +73,30 @@ By default, it renders:
<% end %>
</main>
</div>
<% if @article.present? %>
<script>
(function() {
let viewTracked = false;
const trackView = function() {
if (!viewTracked) {
viewTracked = true;
const img = new Image();
img.src = '<%= request.base_url %>/hc/<%= @portal.slug %>/articles/<%= @article.slug %>.png';
}
};
const addTrackingListeners = function() {
const events = ['mouseenter', 'touchstart', 'focus'];
events.forEach(event => {
document.body.addEventListener(event, function() {
setTimeout(trackView, 5000);
}, { once: true });
});
};
addTrackingListeners();
})();
</script>
<% end %>
</body>
<style>
html.dark {
+9
View File
@@ -62,6 +62,15 @@ Rails.application.configure do
# Disable host check during development
config.hosts = nil
# GitHub Codespaces configuration
if ENV['CODESPACES']
# Allow web console access from any IP
config.web_console.allowed_ips = %w(0.0.0.0/0 ::/0)
# Allow CSRF from codespace URLs
config.force_ssl = false
config.action_controller.forgery_protection_origin_check = false
end
# customize using the environment variables
config.log_level = ENV.fetch('LOG_LEVEL', 'debug').to_sym
+1 -1
View File
@@ -146,7 +146,7 @@
premium: true
- name: chatwoot_v4
display_name: Chatwoot V4
enabled: false
enabled: true
- name: report_v4
display_name: Report V4
enabled: true
+1 -1
View File
@@ -1,6 +1,6 @@
# frozen_string_literal: true
if Rails.env.development?
if Rails.env.development? && ENV['DISABLE_MINI_PROFILER'] != 'true'
require 'rack-mini-profiler'
# initialization is skipped so trigger it
+5 -2
View File
@@ -185,8 +185,6 @@ en:
removed: '%{user_name} removed %{labels}'
sla:
added: '%{user_name} added SLA policy %{sla_name}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
removed: '%{user_name} removed SLA policy %{sla_name}'
muted: '%{user_name} has muted the conversation'
unmuted: '%{user_name} has unmuted the conversation'
@@ -258,6 +256,11 @@ en:
captain:
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
copilot:
using_tool: 'Using tool %{function_name}'
completed_tool_call: 'Completed %{function_name} tool call'
invalid_tool_call: 'Invalid tool call'
tool_not_available: 'Tool not available'
public_portal:
search:
search_placeholder: Search for article by title or body...
+132
View File
@@ -0,0 +1,132 @@
# Markdown Embed Configuration
#
# This file defines patterns and templates for converting URLs into embedded content
# in markdown rendering. Each embed type has:
# - regex: Pattern with named capture groups (?<name>...)
# - template: HTML template with %{capture_group_name} placeholders
#
# To add a new embed type:
# 1. Add a new top-level key
# 2. Define the regex pattern with named capture groups: (?<name>pattern)
# 3. Create an HTML template using %{name} placeholders matching the capture groups
youtube:
regex: 'https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.youtube-nocookie.com/embed/%{video_id}"
frameborder="0"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
loom:
regex: 'https?://(?:www\.)?loom\.com/share/(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.loom.com/embed/%{video_id}"
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
vimeo:
regex: 'https?://(?:www\.)?vimeo\.com/(?<video_id>\d+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://player.vimeo.com/video/%{video_id}?dnt=true"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
mp4:
regex: '(?<link_url>https?://(?:www\.)?.+\.mp4)'
template: |
<video width="640" height="360" controls>
<source src="%{link_url}" type="video/mp4">
Your browser does not support the video tag.
</video>
arcade:
regex: 'https?://(?:www\.)?app\.arcade\.software/share/(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://app.arcade.software/embed/%{video_id}"
frameborder="0"
webkitallowfullscreen
mozallowfullscreen
allowfullscreen
allow="fullscreen"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</iframe>
</div>
wistia:
regex: 'https?://(?:www\.)?[^/]+\.wistia\.com/medias/(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
<script src="https://fast.wistia.com/player.js" async></script>
<script src="https://fast.wistia.com/embed/%{video_id}.js" async type="module"></script>
<style>
wistia-player[media-id='%{video_id}']:not(:defined) {
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/%{video_id}/swatch');
display: block;
filter: blur(5px);
padding-top:56.25%;
}
</style>
<wistia-player
media-id="%{video_id}"
aspect="1.7777777777777777"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</wistia-player>
</div>
bunny:
regex: 'https?://iframe\.mediadelivery\.net/play/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
template: |
<div style="position: relative; padding-top: 56.25%;">
<iframe
src="https://iframe.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
title="Bunny video player"
loading="lazy"
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowfullscreen>
</iframe>
</div>
codepen:
regex: 'https?://(?:www\.)?codepen\.io/(?<user>[^/]+)/pen/(?<pen_id>[^/?]+)'
template: |
<div style="height: 400px; box-sizing: border-box; display: flex; align-items: center; justify-content: center;">
<iframe
height="400"
style="width: 100%;"
scrolling="no"
title="CodePen Embed"
src="https://codepen.io/%{user}/embed/%{pen_id}?default-tab=result"
frameborder="no"
loading="lazy"
allowtransparency="true"
allowfullscreen="true">
</iframe>
</div>
github_gist:
regex: 'https?://gist\.github\.com/(?<username>[^/]+)/(?<gist_id>[a-f0-9]+)'
template: |
<script src="https://gist.github.com/%{username}/%{gist_id}.js"></script>
<noscript>
<div style="border: 1px solid #d1d9e0; border-radius: 6px; padding: 16px; margin: 16px 0; background: #f6f8fa;">
<a href="https://gist.github.com/%{username}/%{gist_id}" target="_blank" style="color: #0969da; text-decoration: none;">
View this gist on GitHub
</a>
</div>
</noscript>
+3 -1
View File
@@ -67,6 +67,7 @@ Rails.application.routes.draw do
end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member
post :reset_access_token, on: :member
end
resources :contact_inboxes, only: [] do
collection do
@@ -127,7 +128,6 @@ Rails.application.routes.draw do
post :unread
post :custom_attributes
get :attachments
post :copilot
get :inbox_assistant
end
end
@@ -296,6 +296,7 @@ Rails.application.routes.draw do
post :auto_offline
put :set_active_account
post :resend_confirmation
post :reset_access_token
end
end
@@ -447,6 +448,7 @@ Rails.application.routes.draw do
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/articles/:article_slug.png', to: 'public/api/v1/portals/articles#tracking_pixel'
get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show'
# ----------------------------------------------------------------------
@@ -1,31 +1,5 @@
module Enterprise::Api::V1::Accounts::ConversationsController
extend ActiveSupport::Concern
included do
before_action :set_assistant, only: [:copilot]
end
def copilot
# First try to get the user's preferred assistant from UI settings or from the request
assistant_id = copilot_params[:assistant_id] || current_user.ui_settings&.dig('preferred_captain_assistant_id')
# Find the assistant either by ID or from inbox
assistant = if assistant_id.present?
Captain::Assistant.find_by(id: assistant_id, account_id: Current.account.id)
else
@conversation.inbox.captain_assistant
end
return render json: { message: I18n.t('captain.copilot_error') } unless assistant
response = Captain::Copilot::ChatService.new(
assistant,
previous_history: copilot_params[:previous_history],
conversation_id: @conversation.display_id,
user_id: Current.user.id
).generate_response(copilot_params[:message])
render json: { message: response['response'] }
end
def inbox_assistant
assistant = @conversation.inbox.captain_assistant
+16 -4
View File
@@ -53,9 +53,21 @@ module Captain::ChatHelper
end
def execute_tool(function_name, arguments, tool_call_id)
persist_message({ content: "Using tool #{function_name}", function_name: function_name }, 'assistant_thinking')
persist_message(
{
content: I18n.t('captain.copilot.using_tool', function_name: function_name),
function_name: function_name
},
'assistant_thinking'
)
result = @tool_registry.send(function_name, arguments)
persist_message({ content: "Completed #{function_name} tool call", function_name: function_name }, 'assistant_thinking')
persist_message(
{
content: I18n.t('captain.copilot.completed_tool_call', function_name: function_name),
function_name: function_name
},
'assistant_thinking'
)
append_tool_response(result, tool_call_id)
end
@@ -67,8 +79,8 @@ module Captain::ChatHelper
end
def process_invalid_tool_call(function_name, tool_call_id)
persist_message({ content: 'Invalid tool call', function_name: function_name }, 'assistant_thinking')
append_tool_response('Tool not available', tool_call_id)
persist_message({ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name }, 'assistant_thinking')
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
end
def append_tool_response(content, tool_call_id)
+1 -1
View File
@@ -60,7 +60,7 @@ class CopilotMessage < ApplicationRecord
def validate_message_attributes
return if message.blank?
allowed_keys = %w[content reasoning function_name]
allowed_keys = %w[content reasoning function_name reply_suggestion]
invalid_keys = message.keys - allowed_keys
errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any?
@@ -0,0 +1,13 @@
module Enterprise::CsatSurveyResponsePolicy
def index?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
def metrics?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
def download?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
end
@@ -74,7 +74,10 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
def system_message
{
role: 'system',
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'])
content: Captain::Llm::SystemPromptsService.copilot_response_generator(
@assistant.config['product_name'],
@tool_registry.tools_summary
)
}
end

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