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
2 changes: 2 additions & 0 deletions src/_locales/de/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
"Confirm": "Bestätigen",
"Clear Conversation": "Konversation löschen",
"Retry": "Erneut versuchen",
"Replace Selection": "Auswahl ersetzen",
"Replace Failed": "Ersetzen fehlgeschlagen",
"Exceeded maximum context length": "Maximale Kontextlänge überschritten, bitte Konversation löschen und erneut versuchen",
"Regenerate the answer after switching model": "Antwort nach dem Wechseln des Modells neu generieren",
"Pin": "Anheften",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@
"Confirm": "Confirm",
"Clear Conversation": "Clear Conversation",
"Retry": "Retry",
"Replace Selection": "Replace Selection",
"Replace Failed": "Replace Failed",
"Exceeded maximum context length": "Exceeded maximum context length, please clear the conversation and try again",
"Regenerate the answer after switching model": "Regenerate the answer after switching model",
"Pin": "Pin",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/es/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"Confirm": "Confirmar",
"Clear Conversation": "Borrar conversación",
"Retry": "Reintentar",
"Replace Selection": "Reemplazar selección",
"Replace Failed": "Error al reemplazar",
"Exceeded maximum context length": "Se superó la longitud máxima del contexto, borre la conversación y vuelva a intentarlo",
"Regenerate the answer after switching model": "Regenerar la respuesta después de cambiar el modelo",
"Pin": "Fijar",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/fr/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"Confirm": "Confirmer",
"Clear Conversation": "Effacer la conversation",
"Retry": "Réessayer",
"Replace Selection": "Remplacer la sélection",
"Replace Failed": "Échec du remplacement",
"Exceeded maximum context length": "Dépassement de la longueur maximale de contexte, veuillez effacer la conversation et réessayer",
"Regenerate the answer after switching model": "Régénérer la réponse après avoir changé de modèle",
"Pin": "Épingler",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/in/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"Confirm": "Konfirmasi",
"Clear Conversation": "Bersihkan Percakapan",
"Retry": "Coba Lagi",
"Replace Selection": "Ganti Teks Terpilih",
"Replace Failed": "Gagal Mengganti",
"Exceeded maximum context length": "Melampaui batas maksimum panjang konteks, harap bersihkan percakapan dan coba lagi",
"Regenerate the answer after switching model": "Hasilkan kembali jawaban setelah beralih ke model lain",
"Pin": "Sematkan",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/it/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"Confirm": "Conferma",
"Clear Conversation": "Pulisci la conversazione",
"Retry": "Riprova",
"Replace Selection": "Sostituisci selezione",
"Replace Failed": "Sostituzione non riuscita",
"Exceeded maximum context length": "Lunghezza massima del contesto superata, si prega di pulire la conversazione e riprovare",
"Regenerate the answer after switching model": "Rigenerare la risposta dopo aver cambiato il modello",
"Pin": "Fissa",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/ja/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"Confirm": "確認",
"Clear Conversation": "会話をクリア",
"Retry": "再試行",
"Replace Selection": "選択範囲を置換",
"Replace Failed": "置換に失敗しました",
"Exceeded maximum context length": "最大コンテキスト長を超えました。会話をクリアして再試行してください",
"Regenerate the answer after switching model": "モデルを切り替えた後に回答を再生成",
"Pin": "ピン留め",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/ko/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"Confirm": "확인",
"Clear Conversation": "대화 내용 지우기",
"Retry": "재시도",
"Replace Selection": "선택 영역 바꾸기",
"Replace Failed": "바꾸기 실패",
"Exceeded maximum context length": "최대 컨텍스트 길이를 초과하였습니다. 대화 내용을 지우고 다시 시도해주세요.",
"Regenerate the answer after switching model": "모델 전환 후 대답 다시 생성",
"Pin": "고정",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/pt/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"Confirm": "Confirmar",
"Clear Conversation": "Limpar Conversa",
"Retry": "Tentar novamente",
"Replace Selection": "Substituir seleção",
"Replace Failed": "Falha ao substituir",
"Exceeded maximum context length": "Ultrapassou o comprimento máximo do contexto. Limpe a conversa e tente novamente",
"Regenerate the answer after switching model": "Regenerar a resposta após trocar o modelo",
"Pin": "Fixar",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/ru/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@
"Confirm": "Подтверждение",
"Clear Conversation": "Очистить беседу",
"Retry": "Повторить",
"Replace Selection": "Заменить выделенное",
"Replace Failed": "Не удалось заменить",
"Exceeded maximum context length": "Превышена максимальная длина контекста, очистите беседу и повторите попытку",
"Regenerate the answer after switching model": "Генерировать ответ после смены модели",
"Pin": "Закрепить",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/tr/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"Confirm": "Onayla",
"Clear Conversation": "Konuşmayı Temizle",
"Retry": "Tekrar Dene",
"Replace Selection": "Seçimi Değiştir",
"Replace Failed": "Değiştirme Başarısız",
"Exceeded maximum context length": "Maksimum bağlam uzunluğu aşıldı",
"Regenerate the answer after switching model": "Modeli değiştirdikten sonra cevabı yeniden oluştur",
"Pin": "Sabitle",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/zh-hans/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@
"Confirm": "确认",
"Clear Conversation": "清理对话",
"Retry": "重试",
"Replace Selection": "替换选中文本",
"Replace Failed": "替换失败",
"Exceeded maximum context length": "超出最大上下文长度, 请清理对话并重试",
"Regenerate the answer after switching model": "快捷切换模型时自动重新生成回答",
"Pin": "固定侧边",
Expand Down
2 changes: 2 additions & 0 deletions src/_locales/zh-hant/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@
"Confirm": "確認",
"Clear Conversation": "清除對話",
"Retry": "重試",
"Replace Selection": "取代選取文字",
"Replace Failed": "取代失敗",
"Exceeded maximum context length": "超出最大上下文長度,請清除對話並重試",
"Regenerate the answer after switching model": "切換模型後自動重新產生回答",
"Pin": "固定側邊",
Expand Down
6 changes: 6 additions & 0 deletions src/components/ConversationCard/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,11 @@ function ConversationCard(props) {
type={data.type}
descName={data.type === 'answer' && currentAiName}
onRetry={idx === conversationItemData.length - 1 ? retryFn : null}
onReplace={
data.type === 'answer' && data.done && props.onReplaceContent
? () => props.onReplaceContent(data.content.replace(/\n<hr\/>$/, ''))
: null
}
/>
))}
</div>
Expand Down Expand Up @@ -641,6 +646,7 @@ ConversationCard.propTypes = {
session: PropTypes.object.isRequired,
question: PropTypes.string,
onUpdate: PropTypes.func,
onReplaceContent: PropTypes.func,
draggable: PropTypes.bool,
closeable: PropTypes.bool,
onClose: PropTypes.func,
Expand Down
5 changes: 4 additions & 1 deletion src/components/ConversationItem/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { memo, useState } from 'react'
import { ChevronDownIcon, XCircleIcon, SyncIcon } from '@primer/octicons-react'
import CopyButton from '../CopyButton'
import ReadButton from '../ReadButton'
import ReplaceButton from '../ReplaceButton'
import PropTypes from 'prop-types'
import MarkdownRender from '../MarkdownRender/markdown.jsx'
import { useTranslation } from 'react-i18next'
Expand All @@ -16,7 +17,7 @@ AnswerTitle.propTypes = {
descName: PropTypes.string,
}

export function ConversationItem({ type, content, descName, onRetry }) {
export function ConversationItem({ type, content, descName, onRetry, onReplace }) {
const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(false)

Expand Down Expand Up @@ -62,6 +63,7 @@ export function ConversationItem({ type, content, descName, onRetry }) {
<SyncIcon size={14} />
</span>
)}
{descName && onReplace && <ReplaceButton onReplace={onReplace} size={14} />}
{descName && (
<CopyButton contentFn={() => content.replace(/\n<hr\/>$/, '')} size={14} />
)}
Expand Down Expand Up @@ -130,6 +132,7 @@ ConversationItem.propTypes = {
content: PropTypes.string.isRequired,
descName: PropTypes.string,
onRetry: PropTypes.func,
onReplace: PropTypes.func,
}

export default memo(ConversationItem)
13 changes: 12 additions & 1 deletion src/components/FloatingToolbar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { cloneElement, useCallback, useEffect, useState } from 'react'
import ConversationCard from '../ConversationCard'
import PropTypes from 'prop-types'
import { config as toolsConfig } from '../../content-script/selection-tools'
import { getClientPosition, isMobile, setElementPositionInViewport } from '../../utils'
import {
getClientPosition,
isMobile,
replaceCapturedSelection,
setElementPositionInViewport,
} from '../../utils'
import Draggable from 'react-draggable'
import { useClampWindowSize } from '../../hooks/use-clamp-window-size'
import { useTranslation } from 'react-i18next'
Expand Down Expand Up @@ -106,6 +111,11 @@ function FloatingToolbar(props) {
dockable={props.dockable}
onDock={onDock}
onUpdate={onUpdate}
onReplaceContent={
props.capturedSelection
? (content) => replaceCapturedSelection(props.capturedSelection, content)
: null
}
waitForTrigger={prompt && !triggered && !selection}
/>
</div>
Expand Down Expand Up @@ -168,6 +178,7 @@ FloatingToolbar.propTypes = {
closeable: PropTypes.bool,
dockable: PropTypes.bool,
prompt: PropTypes.string,
capturedSelection: PropTypes.object,
}

export default FloatingToolbar
49 changes: 49 additions & 0 deletions src/components/ReplaceButton/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useEffect, useRef, useState } from 'react'
import { CheckIcon, ReplyIcon, XIcon } from '@primer/octicons-react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'

ReplaceButton.propTypes = {
onReplace: PropTypes.func.isRequired,
size: PropTypes.number.isRequired,
className: PropTypes.string,
}

function ReplaceButton({ className, onReplace, size }) {
const { t } = useTranslation()
const [status, setStatus] = useState('idle')
const timeoutRef = useRef(null)

useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [])

const onClick = () => {
setStatus(onReplace() ? 'replaced' : 'failed')

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.

medium

Robustness of Callback Return Value Check

Currently, ReplaceButton assumes that onReplace() strictly returns a truthy value on success. However, in React, many callback functions do not return a value (i.e., they return undefined). If another developer reuses ReplaceButton or ConversationCard with a standard void-returning function, the button will incorrectly show the "Replace Failed" state.

Suggestion

Only treat the result as a failure if it explicitly returns false.

Suggested change
setStatus(onReplace() ? 'replaced' : 'failed')
const success = onReplace()
setStatus(success !== false ? 'replaced' : 'failed')

if (timeoutRef.current) clearTimeout(timeoutRef.current)

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.

SUGGESTION: setTimeout created in onClick is not cleared on unmount

If the component unmounts before the 800ms timeout fires, the callback runs and calls setStatus on an unmounted component. Add an effect cleanup (or useEffect) to clear the timer on unmount.

timeoutRef.current = setTimeout(() => {
setStatus('idle')
timeoutRef.current = null
}, 800)
}
Comment on lines +15 to +30

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.

medium

Add a useEffect hook to clear the pending timeout when the component unmounts. This prevents memory leaks and potential state updates on unmounted components if the button is unmounted while the success/failure status timeout is still active.

  const timeoutRef = useRef(null)

  useEffect(() => {
    return () => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current)
    }
  }, [])

  const onClick = () => {
    setStatus(onReplace() ? 'replaced' : 'failed')
    if (timeoutRef.current) clearTimeout(timeoutRef.current)
    timeoutRef.current = setTimeout(() => {
      setStatus('idle')
      timeoutRef.current = null
    }, 800)
  }

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<span
title={status === 'failed' ? t('Replace Failed') : t('Replace Selection')}
className={`gpt-util-icon ${className ? className : ''}`}
onClick={onClick}
>
Comment on lines +33 to +37
{status === 'replaced' ? (
<CheckIcon size={size} />
) : status === 'failed' ? (
<XIcon size={size} />
) : (
<ReplyIcon size={size} />
)}
</span>
)
}

export default ReplaceButton
54 changes: 39 additions & 15 deletions src/content-script/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
setUserConfig,
} from '../config/index.mjs'
import {
captureEditableSelection,
createElementAtPosition,
cropText,
endsWithQuestionMark,
Expand Down Expand Up @@ -236,12 +237,14 @@ const deleteToolbar = () => {
}
}

const createSelectionTools = async (toolbarContainerElement, selection) => {
const createSelectionTools = async (toolbarContainerElement, selection, capturedSelection) => {
console.debug(
'[content] createSelectionTools called with selection:',
selection,
'and container:',
'container:',
toolbarContainerElement,
'captured editable selection kind:',
capturedSelection?.kind,
)
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
try {
toolbarContainerElement.className = 'chatgptbox-toolbar-container'
Expand All @@ -256,6 +259,7 @@ const createSelectionTools = async (toolbarContainerElement, selection) => {
selection={selection}
container={toolbarContainerElement}
dockable={true}
capturedSelection={capturedSelection}
/>,
toolbarContainerElement,
)
Expand Down Expand Up @@ -291,11 +295,17 @@ async function prepareForSelectionTools() {
deleteToolbar()
setTimeout(async () => {
try {
const selection = window
.getSelection()
?.toString()
.trim()
.replace(/^-+|-+$/g, '')
const capturedSelection = captureEditableSelection()
const selection =
window
.getSelection()
?.toString()
.trim()
.replace(/^-+|-+$/g, '') ||
// Firefox does not expose text field selections via window.getSelection()
(capturedSelection?.kind === 'text-field'
? capturedSelection.text.trim().replace(/^-+|-+$/g, '')
: '')
Comment on lines +298 to +308

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.

Action required

1. Firefox keydown closes toolbar 🐞 Bug ≡ Correctness

The PR adds a Firefox fallback to derive selected text from captured text-field selection, but the
keydown cleanup path still checks only window.getSelection(). In Firefox (where text-field selection
isn’t exposed via window.getSelection), this can incorrectly delete the selection-tools toolbar even
while a text-field selection still exists.
Agent Prompt
### Issue description
Selection-tools now supports Firefox text-field selections via `captureEditableSelection()`, but the toolbar auto-cleanup on `keydown` still relies on `window.getSelection()`. In Firefox this can evaluate to empty for input/textarea selections, causing premature toolbar deletion.

### Issue Context
The PR explicitly notes Firefox does not expose text-field selections via `window.getSelection()`, and adds a fallback for detecting the selected text on mouseup/touchend. The keydown cleanup should use the same strategy (or directly inspect `selectionStart/selectionEnd`).

### Fix Focus Areas
- src/content-script/index.jsx[365-377]
- src/content-script/index.jsx[298-308]

### Implementation notes
- In the keydown `setTimeout`, decide “selection exists” using either:
  - `window.getSelection()?.toString().trim()` (for contenteditable/static selections), OR
  - for INPUT/TEXTAREA: `e.target.selectionStart < e.target.selectionEnd` (or `captureEditableSelection()` and check `kind === 'text-field'` and non-empty text).
- Only call `deleteToolbar()` when *both* checks indicate there is no selection.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

if (selection) {
console.debug('[content] Text selected. Length:', selection.length)
let position
Expand Down Expand Up @@ -325,7 +335,7 @@ async function prepareForSelectionTools() {
}
console.debug('[content] Toolbar position:', position)
toolbarContainer = createElementAtPosition(position.x, position.y)
await createSelectionTools(toolbarContainer, selection)
await createSelectionTools(toolbarContainer, selection, capturedSelection)
} else {
console.debug('[content] No text selected on mouseup.')
}
Expand Down Expand Up @@ -403,16 +413,22 @@ async function prepareForSelectionToolsTouch() {
deleteToolbar()
setTimeout(async () => {
try {
const selection = window
.getSelection()
?.toString()
.trim()
.replace(/^-+|-+$/g, '')
const capturedSelection = captureEditableSelection()
const selection =
window
.getSelection()
?.toString()
.trim()
.replace(/^-+|-+$/g, '') ||
// Firefox does not expose text field selections via window.getSelection()
(capturedSelection?.kind === 'text-field'
? capturedSelection.text.trim().replace(/^-+|-+$/g, '')
: '')
if (selection) {
console.debug('[content] Text selected via touch:', selection)
const touch = e.changedTouches[0]
toolbarContainer = createElementAtPosition(touch.pageX + 20, touch.pageY + 20)
await createSelectionTools(toolbarContainer, selection)
await createSelectionTools(toolbarContainer, selection, capturedSelection)
} else {
console.debug('[content] No text selected on touchend.')
}
Expand Down Expand Up @@ -443,7 +459,7 @@ async function prepareForSelectionToolsTouch() {
})
}

let menuX, menuY
let menuX, menuY, menuCapturedSelection
let rightClickMenuInitialized = false

async function prepareForRightClickMenu() {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Expand All @@ -456,6 +472,9 @@ async function prepareForRightClickMenu() {
document.addEventListener('contextmenu', (e) => {
menuX = e.clientX
menuY = e.clientY
// the menu click arrives asynchronously via the background script, so the
// editable selection has to be captured while it still exists
menuCapturedSelection = captureEditableSelection()

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.

high

Memory Leak Risk

The global variable menuCapturedSelection is assigned a captured selection when the context menu is opened. However, if the user dismisses the context menu without clicking any of the extension's menu items (e.g., by clicking elsewhere on the page or pressing Escape), CREATE_CHAT is never received, and menuCapturedSelection will continue to hold a reference to the DOM element and the Range object indefinitely.

This prevents the DOM element, its owner document, and associated resources from being garbage collected, leading to a memory leak on long-lived pages.

Suggestion

Clear menuCapturedSelection after a reasonable timeout (e.g., 30 seconds) to prevent memory leaks if the menu is dismissed.

    menuCapturedSelection = captureEditableSelection()
    setTimeout(() => {
      if (menuCapturedSelection) {
        menuCapturedSelection = null
      }
    }, 30000)

console.debug(`[content] Context menu opened at X: ${menuX}, Y: ${menuY}`)
})

Expand All @@ -465,6 +484,10 @@ async function prepareForRightClickMenu() {
try {
const data = message.data
let prompt = ''
// only selection tools operate on the selected text; menu tools work
// on the whole page and must not offer replacing the selection
const capturedSelection = data.itemId in toolsConfig ? menuCapturedSelection : null
menuCapturedSelection = null
if (data.itemId in toolsConfig) {
console.debug('[content] Generating prompt from toolsConfig for item:', data.itemId)
prompt = await toolsConfig[data.itemId].genPrompt(data.selectionText)
Expand Down Expand Up @@ -507,6 +530,7 @@ async function prepareForRightClickMenu() {
triggered={true}
closeable={true}
prompt={prompt}
capturedSelection={capturedSelection}
/>,
container,
)
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export * from './limited-fetch'
export * from './open-url'
export * from './parse-float-with-clamp'
export * from './parse-int-with-clamp'
export * from './selection-replace'
export * from './set-element-position-in-viewport'
export * from './eventsource-parser.mjs'
export * from './update-ref-height'
Expand Down
Loading