Skip to content
Open
Changes from 4 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
122 changes: 119 additions & 3 deletions frontend/src/features/tasks/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

'use client';

import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { useTranslation } from '@/hooks/useTranslation';
import { useIsMobile } from '@/features/layout/hooks/useMediaQuery';
Expand All @@ -29,6 +29,7 @@ export default function ChatInput({
const placeholderKey = taskType === 'chat' ? 'chat.placeholder_chat' : 'chat.placeholder_code';
const [isComposing, setIsComposing] = useState(false);
const isMobile = useIsMobile();
const textareaRef = useRef<HTMLTextAreaElement>(null);

const handleCompositionStart = () => {
setIsComposing(true);
Expand All @@ -51,22 +52,137 @@ export default function ChatInput({
}
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle Tab key - insert \t placeholder instead of default tab behavior
if (e.key === 'Tab' && !disabled && !isComposing) {
e.preventDefault();

const textarea = e.target as HTMLTextAreaElement;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;

// Insert \t placeholder at cursor position
const newValue = value.substring(0, start) + '\t' + value.substring(end);

setMessage(newValue);

// Set cursor position after the inserted tab
setTimeout(() => {
textarea.selectionStart = start + 1;
textarea.selectionEnd = start + 1;
}, 0);
return;
}
Comment on lines +55 to +76
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Use textareaRef.current in cursor restoration callback.

After setMessage triggers a parent re-render (line 68), the local textarea variable captured at line 60 may reference a stale DOM node. The setTimeout callback should access textareaRef.current instead to guarantee cursor restoration on the current element.

Apply this diff to fix cursor restoration:

-      // Set cursor position after the inserted tab
-      setTimeout(() => {
-        textarea.selectionStart = start + 1;
-        textarea.selectionEnd = start + 1;
-      }, 0);
+      // Set cursor position after the inserted tab
+      requestAnimationFrame(() => {
+        if (textareaRef.current) {
+          textareaRef.current.selectionStart = start + 1;
+          textareaRef.current.selectionEnd = start + 1;
+        }
+      });

Verify: Tab insertion feature is not mentioned in PR objectives.

The PR objectives describe line formatting, auto-focus, and Shift+Enter support, but don't mention Tab key handling. Confirm this feature is intentional and within scope.


🏁 Script executed:

#!/bin/bash
# Examine the full ChatInput.tsx component to understand structure and ref usage
cat frontend/src/features/tasks/components/ChatInput.tsx

Length of output: 4308


🏁 Script executed:

#!/bin/bash
# Search for textareaRef definition and usage in the component file
rg -n "textareaRef" frontend/src/features/tasks/components/ChatInput.tsx

Length of output: 176


🏁 Script executed:

#!/bin/bash
# Check for any PR description or objectives
fd -e md -e txt . --type f | xargs grep -l "Tab\|tab" 2>/dev/null | head -5

Length of output: 98


🏁 Script executed:

#!/bin/bash
# Search for PR metadata (if available in git)
git log --all --oneline --grep="Tab\|tab" 2>/dev/null | head -10

Length of output: 421


Use textareaRef.current for cursor restoration instead of stale event target reference.

Lines 71–74 and 95–98 capture the textarea reference from e.target, then call setMessage() which triggers a parent re-render before restoring cursor position. While the event target reference remains valid, using textareaRef.current is more robust and follows React patterns by always referencing the current DOM node through the ref.

Apply this diff to both Tab (lines 71–74) and Shift+Enter (lines 95–98) handlers:

-      // Set cursor position after the inserted tab
-      setTimeout(() => {
-        textarea.selectionStart = start + 1;
-        textarea.selectionEnd = start + 1;
-      }, 0);
+      // Set cursor position after the inserted tab
+      setTimeout(() => {
+        if (textareaRef.current) {
+          textareaRef.current.selectionStart = start + 1;
+          textareaRef.current.selectionEnd = start + 1;
+        }
+      }, 0);

Also fix lines 95–98 similarly for the Shift+Enter handler (start/end positions differ but same pattern applies).


Tab insertion feature is in scope (commit 721db18 confirms intentional addition).


// Handle Backspace key - remove auto-indented whitespace
if (e.key === 'Backspace' && !disabled && !isComposing) {
const textarea = e.target as HTMLTextAreaElement;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;

// Only handle when cursor is at a single position (no selection)
if (start === end && start > 0) {
// Get the current line and previous character
const lines = value.substring(0, start).split('\n');
const currentLine = lines[lines.length - 1];

// Check if we're at the beginning of a line and the line contains only whitespace
if (currentLine.trim() === '' && currentLine.length > 0) {
e.preventDefault();

// Remove the entire line of whitespace and the newline character
const previousLines = lines.slice(0, -1);
const remainingText = value.substring(start);
const newValue = previousLines.join('\n') + (previousLines.length > 0 ? '\n' : '') + remainingText;

setMessage(newValue);

// Set cursor position at the end of the previous line
const newCursorPos = previousLines.length > 0 ? previousLines.join('\n').length + 1 : 0;
setTimeout(() => {
textarea.selectionStart = newCursorPos;
textarea.selectionEnd = newCursorPos;
}, 0);
return;
}

// Check if we're deleting whitespace that was auto-indented
const charBeforeCursor = value.substring(start - 1, start);
if (charBeforeCursor === ' ' || charBeforeCursor === '\t') {
// Get the line start position
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineContent = value.substring(lineStart, start);

// If the line contains only whitespace up to cursor, remove all whitespace
if (lineContent.trim() === '') {
e.preventDefault();
const newValue = value.substring(0, lineStart) + value.substring(start);
setMessage(newValue);

setTimeout(() => {
textarea.selectionStart = lineStart;
textarea.selectionEnd = lineStart;
}, 0);
return;
}
}
}
}
Comment on lines +78 to +132
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Backspace handler uses stale textarea reference in cursor restoration.

The Backspace handler has the same cursor restoration issue as the Tab handler. Lines 104-107 and 124-127 use the captured textarea variable in setTimeout callbacks after calling setMessage, which may reference a stale DOM node.

Apply this diff to fix both cursor restoration points:

           setMessage(newValue);

           // Set cursor position at the end of the previous line
           const newCursorPos = previousLines.length > 0 ? previousLines.join('\n').length + 1 : 0;
-          setTimeout(() => {
-            textarea.selectionStart = newCursorPos;
-            textarea.selectionEnd = newCursorPos;
-          }, 0);
+          requestAnimationFrame(() => {
+            if (textareaRef.current) {
+              textareaRef.current.selectionStart = newCursorPos;
+              textareaRef.current.selectionEnd = newCursorPos;
+            }
+          });
           return;
         }

And for the second cursor restoration:

             setMessage(newValue);

-            setTimeout(() => {
-              textarea.selectionStart = lineStart;
-              textarea.selectionEnd = lineStart;
-            }, 0);
+            requestAnimationFrame(() => {
+              if (textareaRef.current) {
+                textareaRef.current.selectionStart = lineStart;
+                textareaRef.current.selectionEnd = lineStart;
+              }
+            });
             return;
           }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Handle Backspace key - remove auto-indented whitespace
if (e.key === 'Backspace' && !disabled && !isComposing) {
const textarea = e.target as HTMLTextAreaElement;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
// Only handle when cursor is at a single position (no selection)
if (start === end && start > 0) {
// Get the current line and previous character
const lines = value.substring(0, start).split('\n');
const currentLine = lines[lines.length - 1];
// Check if we're at the beginning of a line and the line contains only whitespace
if (currentLine.trim() === '' && currentLine.length > 0) {
e.preventDefault();
// Remove the entire line of whitespace and the newline character
const previousLines = lines.slice(0, -1);
const remainingText = value.substring(start);
const newValue = previousLines.join('\n') + (previousLines.length > 0 ? '\n' : '') + remainingText;
setMessage(newValue);
// Set cursor position at the end of the previous line
const newCursorPos = previousLines.length > 0 ? previousLines.join('\n').length + 1 : 0;
setTimeout(() => {
textarea.selectionStart = newCursorPos;
textarea.selectionEnd = newCursorPos;
}, 0);
return;
}
// Check if we're deleting whitespace that was auto-indented
const charBeforeCursor = value.substring(start - 1, start);
if (charBeforeCursor === ' ' || charBeforeCursor === '\t') {
// Get the line start position
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineContent = value.substring(lineStart, start);
// If the line contains only whitespace up to cursor, remove all whitespace
if (lineContent.trim() === '') {
e.preventDefault();
const newValue = value.substring(0, lineStart) + value.substring(start);
setMessage(newValue);
setTimeout(() => {
textarea.selectionStart = lineStart;
textarea.selectionEnd = lineStart;
}, 0);
return;
}
}
}
}
// Handle Backspace key - remove auto-indented whitespace
if (e.key === 'Backspace' && !disabled && !isComposing) {
const textarea = e.target as HTMLTextAreaElement;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
// Only handle when cursor is at a single position (no selection)
if (start === end && start > 0) {
// Get the current line and previous character
const lines = value.substring(0, start).split('\n');
const currentLine = lines[lines.length - 1];
// Check if we're at the beginning of a line and the line contains only whitespace
if (currentLine.trim() === '' && currentLine.length > 0) {
e.preventDefault();
// Remove the entire line of whitespace and the newline character
const previousLines = lines.slice(0, -1);
const remainingText = value.substring(start);
const newValue = previousLines.join('\n') + (previousLines.length > 0 ? '\n' : '') + remainingText;
setMessage(newValue);
// Set cursor position at the end of the previous line
const newCursorPos = previousLines.length > 0 ? previousLines.join('\n').length + 1 : 0;
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = newCursorPos;
textareaRef.current.selectionEnd = newCursorPos;
}
});
return;
}
// Check if we're deleting whitespace that was auto-indented
const charBeforeCursor = value.substring(start - 1, start);
if (charBeforeCursor === ' ' || charBeforeCursor === '\t') {
// Get the line start position
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineContent = value.substring(lineStart, start);
// If the line contains only whitespace up to cursor, remove all whitespace
if (lineContent.trim() === '') {
e.preventDefault();
const newValue = value.substring(0, lineStart) + value.substring(start);
setMessage(newValue);
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = lineStart;
textareaRef.current.selectionEnd = lineStart;
}
});
return;
}
}
}
}
🤖 Prompt for AI Agents
In frontend/src/features/tasks/components/ChatInput.tsx around lines 78–132, the
Backspace handler captures the local `textarea` variable and uses it inside
setTimeout callbacks which can reference a stale DOM node; replace those usages
by reading the current textarea from a stable ref (e.g., textareaRef.current) or
by re-querying the element by id inside the timeout, then check for null before
setting selection. Concretely: ensure you store selection positions immediately,
call setMessage, and in the setTimeout use textareaRef.current (or
document.getElementById(savedId)) to restore selection (with null guard) instead
of the captured `textarea`; apply this to both cursor restoration points.


// Handle Enter key for auto-indentation (only when Shift+Enter)
if (e.key === 'Enter' && e.shiftKey && !disabled && !isComposing) {
e.preventDefault();

const textarea = e.target as HTMLTextAreaElement;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;

// Get the current line and its leading whitespace
const lines = value.substring(0, start).split('\n');
const currentLine = lines[lines.length - 1];
const leadingWhitespace = currentLine.match(/^\s*/)?.[0] || '';

// Insert new line with preserved indentation
const newValue = value.substring(0, start) + '\n' + leadingWhitespace + value.substring(end);

setMessage(newValue);

// Set cursor position after the inserted whitespace
setTimeout(() => {
textarea.selectionStart = start + 1 + leadingWhitespace.length;
textarea.selectionEnd = start + 1 + leadingWhitespace.length;
}, 0);
}
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use textareaRef.current in cursor restoration callback.

Similar to the Tab handler, after setMessage triggers a re-render (line 95), the local textarea variable captured at line 82 may reference a stale DOM node. The setTimeout callback should access textareaRef.current to ensure reliable cursor restoration.

Apply this diff to fix cursor restoration:

-      // Set cursor position after the inserted whitespace
-      setTimeout(() => {
-        textarea.selectionStart = start + 1 + leadingWhitespace.length;
-        textarea.selectionEnd = start + 1 + leadingWhitespace.length;
-      }, 0);
+      // Set cursor position after the inserted whitespace
+      requestAnimationFrame(() => {
+        if (textareaRef.current) {
+          textareaRef.current.selectionStart = start + 1 + leadingWhitespace.length;
+          textareaRef.current.selectionEnd = start + 1 + leadingWhitespace.length;
+        }
+      });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Handle Enter key for auto-indentation (only when Shift+Enter)
if (e.key === 'Enter' && e.shiftKey && !disabled && !isComposing) {
e.preventDefault();
const textarea = e.target as HTMLTextAreaElement;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
// Get the current line and its leading whitespace
const lines = value.substring(0, start).split('\n');
const currentLine = lines[lines.length - 1];
const leadingWhitespace = currentLine.match(/^\s*/)?.[0] || '';
// Insert new line with preserved indentation
const newValue = value.substring(0, start) + '\n' + leadingWhitespace + value.substring(end);
setMessage(newValue);
// Set cursor position after the inserted whitespace
setTimeout(() => {
textarea.selectionStart = start + 1 + leadingWhitespace.length;
textarea.selectionEnd = start + 1 + leadingWhitespace.length;
}, 0);
}
};
// Handle Enter key for auto-indentation (only when Shift+Enter)
if (e.key === 'Enter' && e.shiftKey && !disabled && !isComposing) {
e.preventDefault();
const textarea = e.target as HTMLTextAreaElement;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
// Get the current line and its leading whitespace
const lines = value.substring(0, start).split('\n');
const currentLine = lines[lines.length - 1];
const leadingWhitespace = currentLine.match(/^\s*/)?.[0] || '';
// Insert new line with preserved indentation
const newValue = value.substring(0, start) + '\n' + leadingWhitespace + value.substring(end);
setMessage(newValue);
// Set cursor position after the inserted whitespace
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = start + 1 + leadingWhitespace.length;
textareaRef.current.selectionEnd = start + 1 + leadingWhitespace.length;
}
});
}
};
🤖 Prompt for AI Agents
In frontend/src/features/tasks/components/ChatInput.tsx around lines 78 to 103,
the Enter+Shift auto-indent handler captures a local textarea variable and then
uses it inside the setTimeout callback, which can reference a stale DOM node
after setMessage triggers a re-render; replace uses of the captured textarea
inside the timeout with textareaRef.current, guard against null, and set
selectionStart/selectionEnd on textareaRef.current using the computed start + 1
+ leadingWhitespace.length so the cursor restoration is reliable after the
re-render.


// Auto-focus on mount
useEffect(() => {
if (textareaRef.current && !disabled) {
textareaRef.current.focus();
}
}, [disabled]);

return (
<div className="w-full">
<TextareaAutosize
ref={textareaRef}
value={message}
onChange={e => {
if (!disabled) setMessage(e.target.value);
}}
onKeyDown={handleKeyPress}
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
placeholder={t(placeholderKey)}
className={`w-full p-3 bg-transparent custom-scrollbar text-text-primary text-base placeholder:text-text-muted placeholder:text-base focus:outline-none data-[focus]:outline-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={disabled}
minRows={isMobile ? 2 : 3}
maxRows={isMobile ? 6 : 8}
style={{ resize: 'none', overflow: 'auto' }}
style={{ resize: 'none', overflow: 'auto', whiteSpace: 'pre-wrap', wordWrap: 'break-word' }}
/>
</div>
);
Expand Down