Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/chat_widget/src/assets/translations/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"modal.confirm": "تأكيد",
"composer.placeholder": "اكتب رسالة...",
"composer.send": "إرسال الرسالة",
"composer.messageTooLong": "الرسالة طويلة جدًا",
"error.fileTooLarge": "الملف كبير جدًا",
"error.totalTooLarge": "إجمالي حجم الملفات كبير جدًا",
"error.unsupportedType": "نوع الملف غير مدعوم",
Expand Down
1 change: 1 addition & 0 deletions components/chat_widget/src/assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"modal.confirm": "Confirm",
"composer.placeholder": "Type a message...",
"composer.send": "Send message",
"composer.messageTooLong": "Message is too long",
"error.fileTooLarge": "File too large",
"error.totalTooLarge": "Total file size too large",
"error.unsupportedType": "Unsupported file type",
Expand Down
1 change: 1 addition & 0 deletions components/chat_widget/src/assets/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"modal.confirm": "Confirmar",
"composer.placeholder": "Escribe un mensaje...",
"composer.send": "Enviar mensaje",
"composer.messageTooLong": "El mensaje es demasiado largo",
"error.fileTooLarge": "Archivo demasiado grande",
"error.totalTooLarge": "Tamaño total de archivos demasiado grande",
"error.unsupportedType": "Tipo de archivo no soportado",
Expand Down
1 change: 1 addition & 0 deletions components/chat_widget/src/assets/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"modal.confirm": "Confirmer",
"composer.placeholder": "Tapez un message...",
"composer.send": "Envoyer le message",
"composer.messageTooLong": "Le message est trop long",
"error.fileTooLarge": "Fichier trop volumineux",
"error.totalTooLarge": "Taille totale des fichiers trop importante",
"error.unsupportedType": "Type de fichier non pris en charge",
Expand Down
1 change: 1 addition & 0 deletions components/chat_widget/src/assets/translations/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"modal.confirm": "पुष्टि करें",
"composer.placeholder": "संदेश लिखें...",
"composer.send": "संदेश भेजें",
"composer.messageTooLong": "संदेश बहुत लंबा है",
"error.fileTooLarge": "फ़ाइल बहुत बड़ी है",
"error.totalTooLarge": "कुल फ़ाइल आकार बहुत बड़ा है",
"error.unsupportedType": "फ़ाइल प्रकार समर्थित नहीं है",
Expand Down
1 change: 1 addition & 0 deletions components/chat_widget/src/assets/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"modal.confirm": "Conferma",
"composer.placeholder": "Scrivi un messaggio...",
"composer.send": "Invia messaggio",
"composer.messageTooLong": "Il messaggio è troppo lungo",
"error.fileTooLarge": "File troppo grande",
"error.totalTooLarge": "Dimensione totale del file troppo grande",
"error.unsupportedType": "Tipo di file non supportato",
Expand Down
1 change: 1 addition & 0 deletions components/chat_widget/src/assets/translations/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"modal.confirm": "Confirmar",
"composer.placeholder": "Digite uma mensagem...",
"composer.send": "Enviar mensagem",
"composer.messageTooLong": "A mensagem é muito longa",
"error.fileTooLarge": "Arquivo muito grande",
"error.totalTooLarge": "Tamanho total do arquivo muito grande",
"error.unsupportedType": "Tipo de arquivo não suportado",
Expand Down
1 change: 1 addition & 0 deletions components/chat_widget/src/assets/translations/sw.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"modal.confirm": "Thibitisha",
"composer.placeholder": "Andika ujumbe...",
"composer.send": "Tuma ujumbe",
"composer.messageTooLong": "Ujumbe ni mrefu sana",
"error.fileTooLarge": "Faili ni kubwa sana",
"error.totalTooLarge": "Jumla ya ukubwa wa faili ni kubwa sana",
"error.unsupportedType": "Aina ya faili haitumiki",
Expand Down
1 change: 1 addition & 0 deletions components/chat_widget/src/assets/translations/uk.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"modal.confirm": "Підтвердити",
"composer.placeholder": "Введіть повідомлення...",
"composer.send": "Надіслати повідомлення",
"composer.messageTooLong": "Повідомлення занадто довге",
"error.fileTooLarge": "Файл занадто великий",
"error.totalTooLarge": "Загальний розмір файлів занадто великий",
"error.unsupportedType": "Непідтримуваний тип файлу",
Expand Down
8 changes: 8 additions & 0 deletions components/chat_widget/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export namespace Components {
* The language code for the widget UI (e.g., 'en', 'es', 'fr'). Defaults to en
*/
"language"?: string;
/**
* Maximum number of characters allowed in a single message (derived from the model's token limit). When set, a live counter is shown and the send button is disabled when exceeded.
*/
"maxCharLimit"?: number;
/**
* The operating mode of the widget. - 'standard': Default floating window with launcher button. - 'kiosk': Fills parent container, always visible, no header or launcher button. The parent element must establish a containing block (e.g. `position: relative`).
* @default 'standard'
Expand Down Expand Up @@ -170,6 +174,10 @@ declare namespace LocalJSX {
* The language code for the widget UI (e.g., 'en', 'es', 'fr'). Defaults to en
*/
"language"?: string;
/**
* Maximum number of characters allowed in a single message (derived from the model's token limit). When set, a live counter is shown and the send button is disabled when exceeded.
*/
"maxCharLimit"?: number;
/**
* The operating mode of the widget. - 'standard': Default floating window with launcher button. - 'kiosk': Fills parent container, always visible, no header or launcher button. The parent element must establish a containing block (e.g. `position: relative`).
* @default 'standard'
Expand Down
21 changes: 21 additions & 0 deletions components/chat_widget/src/components/ocs-chat/ocs-chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,27 @@
}
}

.message-textarea-error {
border-color: var(--error-text-color);
&:focus {
outline-color: var(--error-text-color);
}
}

.char-counter {
@apply text-xs text-right pr-1 pt-0.5;
color: var(--char-counter-color, #9ca3af);
}

.char-counter-warning {
color: var(--char-counter-warning-color, #f59e0b);
}

.char-counter-error {
color: var(--error-text-color);
font-weight: 600;
}

.send-button {
@apply rounded-md font-medium transition-colors duration-200;
padding: 0.5em 1em;
Expand Down
69 changes: 69 additions & 0 deletions components/chat_widget/src/components/ocs-chat/ocs-chat.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -453,4 +453,73 @@ describe('ocs-chat', () => {
expect(starterQuestions).toBeTruthy();
});
});

describe('Character Limit', () => {
it('should not render counter when maxCharLimit is not set', async () => {
const page = await newSpecPage({
components: [OcsChat],
html: `<open-chat-studio-widget chatbot-id="test-bot" visible="true"></open-chat-studio-widget>`,
});
const component = page.rootInstance as OcsChat;
component.sessionId = 'test-session';
await page.waitForChanges();

const counter = page.root?.shadowRoot?.querySelector('.char-counter');
expect(counter).toBeNull();
});

it('should render counter and allow send when message is within limit', async () => {
const page = await newSpecPage({
components: [OcsChat],
html: `<open-chat-studio-widget chatbot-id="test-bot" visible="true" max-char-limit="100"></open-chat-studio-widget>`,
});
const component = page.rootInstance as OcsChat;
component.sessionId = 'test-session';
component.messageInput = 'short message';
await page.waitForChanges();

const counter = page.root?.shadowRoot?.querySelector('.char-counter');
expect(counter).toBeTruthy();
expect(counter?.classList.contains('char-counter-error')).toBe(false);
expect(counter?.classList.contains('char-counter-warning')).toBe(false);

const sendBtn = page.root?.shadowRoot?.querySelector('button[title]') as HTMLButtonElement | null;
// send button should not be disabled due to char limit alone (message is short)
expect(component.messageTooLong).toBe(false);
});

it('should show warning state when message exceeds 80% of limit', async () => {
const page = await newSpecPage({
components: [OcsChat],
html: `<open-chat-studio-widget chatbot-id="test-bot" visible="true" max-char-limit="10"></open-chat-studio-widget>`,
});
const component = page.rootInstance as OcsChat;
component.sessionId = 'test-session';
component.messageInput = '12345678'; // 80% of 10
await page.waitForChanges();

const counter = page.root?.shadowRoot?.querySelector('.char-counter');
expect(counter?.classList.contains('char-counter-warning')).toBe(true);
expect(counter?.classList.contains('char-counter-error')).toBe(false);
expect(component.messageTooLong).toBe(false);
});

it('should show error state and block send when message exceeds limit', async () => {
const page = await newSpecPage({
components: [OcsChat],
html: `<open-chat-studio-widget chatbot-id="test-bot" visible="true" max-char-limit="5"></open-chat-studio-widget>`,
});
const component = page.rootInstance as OcsChat;
component.sessionId = 'test-session';
component.messageInput = 'this is too long';
await page.waitForChanges();

const counter = page.root?.shadowRoot?.querySelector('.char-counter');
expect(counter?.classList.contains('char-counter-error')).toBe(true);
expect(component.messageTooLong).toBe(true);

const sendButton = page.root?.shadowRoot?.querySelector('button.send-button') as HTMLButtonElement | null;
expect(sendButton?.disabled).toBe(true);
});
});
});
33 changes: 29 additions & 4 deletions components/chat_widget/src/components/ocs-chat/ocs-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,18 @@ export class OcsChat {
/**
* @internal
* Optional version number of the chatbot to use. Requires authentication.
* This is for internal use only and is not intended for public-facing widgets.
* Intentionally declared as @Prop() so the Django host page can pass it as
* an HTML attribute; it is not part of the public widget API and should not
* be used by third-party embedders.
*/
@Prop() versionNumber?: number;

/**
* Maximum number of characters allowed in a single message (derived from the model's token limit).
* When set, a live counter is shown and the send button is disabled when exceeded.
*/
@Prop() maxCharLimit?: number;

@State() error: string = '';
@State() messages: ChatMessage[] = [];
@State() sessionId?: string;
Expand Down Expand Up @@ -495,8 +503,17 @@ export class OcsChat {
}
}

private get messageTooLong(): boolean {
return this.maxCharLimit != null && this.messageInput.length > this.maxCharLimit;
}

private get messageNearLimit(): boolean {
return this.maxCharLimit != null && this.messageInput.length > this.maxCharLimit * 0.8;
}

private async sendMessage(message: string): Promise<void> {
if (!message.trim()) return;
if (this.messageTooLong) return;
Comment thread
snopoke marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

❌ Getting worse: Complex Method
OcsChat.sendMessage increases in cyclomatic complexity from 24 to 25, threshold = 11

Suppress

const epoch = this.sessionEpoch;

// Start session if we don't have one yet
Expand Down Expand Up @@ -1748,7 +1765,7 @@ export class OcsChat {
<div class="input-container">
<textarea
ref={el => (this.textareaRef = el)}
class="message-textarea"
class={`message-textarea${this.messageTooLong ? ' message-textarea-error' : ''}`}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

❌ Getting worse: Complex Method
OcsChat.render increases in cyclomatic complexity from 38 to 41, threshold = 11

Suppress

rows={1}
placeholder={this.translationManager.get('composer.placeholder')}
value={this.messageInput}
Expand Down Expand Up @@ -1785,13 +1802,21 @@ export class OcsChat {
</button>
)}
<button
class={`send-button ${!this.isTyping && !this.isLoading && !!this.messageInput.trim() ? 'send-button-enabled' : 'send-button-disabled'}`}
class={`send-button ${
!this.isTyping && !this.isLoading && !!this.messageInput.trim() && !this.messageTooLong ? 'send-button-enabled' : 'send-button-disabled'
}`}
onClick={() => this.sendMessage(this.messageInput)}
disabled={this.isTyping || this.isUploadingFiles || this.isLoading || !this.messageInput.trim()}
disabled={this.isTyping || this.isUploadingFiles || this.isLoading || !this.messageInput.trim() || this.messageTooLong}
title={this.messageTooLong ? this.translationManager.get('composer.messageTooLong') : undefined}
>
{this.isUploadingFiles ? `${this.translationManager.get('status.uploading')}...` : this.translationManager.get('composer.send')}
</button>
</div>
{this.maxCharLimit != null && (
<div class={`char-counter${this.messageTooLong ? ' char-counter-error' : this.messageNearLimit ? ' char-counter-warning' : ''}`}>
{this.messageInput.length} / {this.maxCharLimit}
</div>
)}
Comment on lines +1815 to +1819
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should drop this counter

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, this was based on changes before the #3180 feedback was implemented.

</div>
<div class="flex items-center justify-center text-[0.8em] font-light w-full text-slate-500 py-[2px]">
<p>
Expand Down
1 change: 1 addition & 0 deletions components/chat_widget/src/components/ocs-chat/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ For more information, see the [Open Chat Studio documentation](https://docs.open
| `headerText` | `header-text` | The text to place in the header. | `""` | `undefined` |
| `iconUrl` | `icon-url` | URL of the icon to display on the button. If not provided, uses the default OCS logo. | `string` | `undefined` |
| `language` | `language` | The language code for the widget UI (e.g., 'en', 'es', 'fr'). Defaults to en | `string` | `undefined` |
| `maxCharLimit` | `max-char-limit` | Maximum number of characters allowed in a single message (derived from the model's token limit). When set, a live counter is shown and the send button is disabled when exceeded. | `number` | `undefined` |
| `mode` | `mode` | The operating mode of the widget. - 'standard': Default floating window with launcher button. - 'kiosk': Fills parent container, always visible, no header or launcher button. The parent element must establish a containing block (e.g. `position: relative`). | `"kiosk" \| "standard"` | `'standard'` |
| `newChatConfirmationMessage` | `new-chat-confirmation-message` | The message to display in the new chat confirmation dialog. | `string` | `undefined` |
| `pageContext` | `page-context` | Optional context object to send with each message. This provides page-specific context to the bot. | `{ [x: string]: any; }` | `undefined` |
Expand Down
12 changes: 11 additions & 1 deletion components/chat_widget/src/services/chat-session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,17 @@ export class ChatSessionService {
});

if (!response.ok) {
throw new Error(`Failed to poll task: ${response.statusText}`);
let errorMessage = `Failed to poll task: ${response.statusText}`;
try {
const data = (await response.json()) as { error?: string };
if (data?.error) {
errorMessage = data.error;
}
} catch {
// non-JSON body; keep statusText fallback
}

throw new Error(errorMessage);
}

return response.json() as Promise<ChatTaskPollResponse>;
Expand Down
1 change: 1 addition & 0 deletions templates/chatbots/chat/web_chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
user-name="{{ request.user.get_full_name }}"
{% endif %}
persistent-session="true"
{% if max_char_limit %}max-char-limit="{{ max_char_limit }}"{% endif %}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Description: Look for tests covering the new max character limit behavior.
# Expected: Matches in frontend widget tests and/or Django template/view tests.
fd -i '(test|spec)' --extension ts --extension tsx --extension js --extension jsx --extension py \
  | xargs -r rg -n -C3 'maxCharLimit|max-char-limit|max_char_limit|messageTooLong|char-counter'

Repository: dimagi/open-chat-studio

Length of output: 51


🏁 Script executed:

# First, check overall repository structure
git ls-files | grep -E '(test|spec)' | head -20

Repository: dimagi/open-chat-studio

Length of output: 769


🏁 Script executed:

# Look for test directories
fd -t d -e '' | grep -iE '(test|spec|tests)' | head -20

Repository: dimagi/open-chat-studio

Length of output: 49


🏁 Script executed:

# Check if there are any test files related to chatbot or chat functionality
fd -i 'test_.*\.py|.*_test\.py' | head -20

Repository: dimagi/open-chat-studio

Length of output: 809


🏁 Script executed:

# Search for ChatbotMessage model and any tests that might reference it
rg -l 'ChatbotMessage' --type py | head -20

Repository: dimagi/open-chat-studio

Length of output: 49


🏁 Script executed:

# Search for ChatbotMessage in the codebase to understand the model
rg 'class ChatbotMessage' -A 10 --type py

Repository: dimagi/open-chat-studio

Length of output: 49


🏁 Script executed:

# Look for any test files in chatbots app specifically
fd -i 'test.*\.py' apps/chatbots

Repository: dimagi/open-chat-studio

Length of output: 193


🏁 Script executed:

# Search for character limit, max char, or validation tests
rg -i 'max.*char|char.*limit|messageTooLong' --type py | grep -i test

Repository: dimagi/open-chat-studio

Length of output: 342


🏁 Script executed:

# Check if there are frontend/widget tests
fd -t f -e spec.ts -o -e spec.tsx -o -e test.ts -o -e test.tsx | head -20

Repository: dimagi/open-chat-studio

Length of output: 183


🏁 Script executed:

# Check the chatbot tests to see if any cover character limits
cat apps/chatbots/tests/test_chatbot_views.py

Repository: dimagi/open-chat-studio

Length of output: 20332


🏁 Script executed:

# Search for max_char_limit specifically in the codebase
rg 'max_char_limit|max-char-limit|maxCharLimit' --type py --type ts --type tsx --type js

Repository: dimagi/open-chat-studio

Length of output: 94


🏁 Script executed:

# Check the web_chat.html template to understand the context
cat templates/chatbots/chat/web_chat.html

Repository: dimagi/open-chat-studio

Length of output: 1980


🏁 Script executed:

# Search for where max_char_limit is set/passed in views
rg 'max_char_limit' --type py -B 3 -A 3

Repository: dimagi/open-chat-studio

Length of output: 49


🏁 Script executed:

# Check the ChatbotMessage model for any char limit fields
rg 'class ChatbotMessage' -A 50 --type py

Repository: dimagi/open-chat-studio

Length of output: 49


🏁 Script executed:

# Search for max_char_limit in template context or views related to web_chat
rg 'web_chat|max_char_limit' --type py -B 2 -A 2

Repository: dimagi/open-chat-studio

Length of output: 638


🏁 Script executed:

# Get the full context of web_chat.html rendering in views
rg 'web_chat.html' -B 5 -A 10 --type py

Repository: dimagi/open-chat-studio

Length of output: 1793


🏁 Script executed:

# Search for version_specific_vars to understand what's being passed
rg 'version_specific_vars' --type py -B 3 -A 3

Repository: dimagi/open-chat-studio

Length of output: 7361


🏁 Script executed:

# Look at the entire chatbots/views.py to find where max_char_limit might be set
head -200 apps/chatbots/views.py

Repository: dimagi/open-chat-studio

Length of output: 8158


🏁 Script executed:

# Check if max_char_limit is being set anywhere in the entire codebase
rg 'max_char_limit' --type py

Repository: dimagi/open-chat-studio

Length of output: 49


🏁 Script executed:

# Check what context variables are actually being passed to the template
grep -n 'version_specific_vars\|web_chat.html' apps/chatbots/views.py -A 5 -B 5

Repository: dimagi/open-chat-studio

Length of output: 2381


🏁 Script executed:

# Double-check: is max_char_limit set anywhere through a middleware or context processor?
rg 'max_char_limit' --type py --type ts --type tsx --type js

Repository: dimagi/open-chat-studio

Length of output: 94


🏁 Script executed:

# Check context processors or template setup that might inject it
rg 'context_processor|template' apps/chatbots/views.py -B 2 -A 2

Repository: dimagi/open-chat-studio

Length of output: 2977


Backend context variable missing—max_char_limit is never passed to the template.

The template references max_char_limit at line 38, but neither chatbot_session_view() nor _chatbot_chat_ui() includes it in the context variables passed to web_chat.html. The widget attribute will never render because the variable is undefined. Add max_char_limit to version_specific_vars in both views, and add tests covering both the template rendering and widget enforcement of the limit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@templates/chatbots/chat/web_chat.html` at line 38, The template uses
max_char_limit but neither chatbot_session_view() nor _chatbot_chat_ui()
includes it in the context, so add max_char_limit into the version_specific_vars
dict returned by both functions (ensure the key name matches the template
attribute: max_char_limit) and propagate any existing config/default logic used
for character limits; then add unit tests that render web_chat.html via both
chatbot_session_view and _chatbot_chat_ui to assert the attribute is present in
the rendered HTML and an integration/widget test that enforces the char limit
when sending messages.

></open-chat-studio-widget>
</div>
{% else %}
Expand Down
Loading