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
14 changes: 14 additions & 0 deletions src/background/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ function setPortProxy(port, proxyTabId) {
console.debug('[background] Main port closed; skipping proxy message.')
return
}
if (msg.done || msg.error) port._generating = false

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

Use optional chaining msg?.done and msg?.error to prevent potential TypeError if msg is null or undefined, adhering to defensive programming guidelines.

Suggested change
if (msg.done || msg.error) port._generating = false
if (msg?.done || msg?.error) port._generating = false

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

If msg is null or undefined, accessing msg.done or msg.error will throw a TypeError and crash the port's message listener. Since msg can potentially be null or non-object in extension messaging, it is safer to use optional chaining here.

Suggested change
if (msg.done || msg.error) port._generating = false
if (msg?.done || msg?.error) port._generating = false

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

If msg is null or undefined, accessing msg.done or msg.error will throw a TypeError and crash the background script's message listener. Since msg can potentially be null/undefined in extension message passing, you should use optional chaining here to prevent runtime crashes.

Suggested change
if (msg.done || msg.error) port._generating = false
if (msg?.done || msg?.error) port._generating = false

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

Use optional chaining (msg?.done and msg?.error) to prevent potential TypeError if msg is null or undefined, ensuring robust defensive programming.

Suggested change
if (msg.done || msg.error) port._generating = false
if (msg?.done || msg?.error) port._generating = false

try {
port.postMessage(msg)
} catch (e) {
Expand Down Expand Up @@ -156,6 +157,16 @@ function setPortProxy(port, proxyTabId) {
const proxyRef = port.proxy
port.proxy = null
port._proxyTabId = null
if (port._generating) {
port._generating = false
if (!port._isClosed) {
try {
port.postMessage({ done: true })
} catch (e) {
console.warn('[background] Error posting done on proxy disconnect:', e)
}
}
Comment on lines +160 to +168

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.

Remediation recommended

1. Done lacks session update 🐞 Bug ≡ Correctness

When the proxy tab disconnects mid-generation, setPortProxy() posts { done: true } without a `{
session }`, so the UI marks the answer complete and becomes ready while
session.conversationRecords remains stale. As a result, saving/exporting or persisting the
conversation can omit the partial answer the user saw.
Agent Prompt
### Issue description
On proxy disconnect during an in-flight generation, the background sends `{ done: true }` to the UI to unstick it, but does not provide an updated `{ session }`. The UI only updates `session` when `msg.session` is present, so the partial assistant answer that was streamed to the UI is not persisted into `session.conversationRecords`, which is later used for “Save Conversation” and for rehydration/persistence.

### Issue Context
- Background disconnect path sends done-only.
- UI marks done/ready on `msg.done`, but does not update `session` unless `msg.session` exists.
- Save/export and initial rehydration are based on `session.conversationRecords`.

### Fix Focus Areas
- src/background/index.mjs[154-169]
- src/components/ConversationCard/index.jsx[97-114]
- src/components/ConversationCard/index.jsx[175-186]
- src/components/ConversationCard/index.jsx[562-574]
- src/pages/IndependentPanel/App.jsx[169-179]

### Suggested fix
Implement a way to persist the currently displayed partial answer into the session when a proxy disconnect triggers a done-only terminal signal.

Two viable approaches:
1) **UI-side (recommended):** In `ConversationCard.portMessageListener`, when receiving `msg.done === true` **and** `msg.session` is missing, derive the last Q/A from current UI state (or track latest answer text in a ref) and `pushRecord`-equivalent into a cloned `session`, then `setSession(updatedSession)` before setting ready.
   - Guard against double-persist if a subsequent `{session}` arrives.
2) **Protocol change:** Add a marker flag from background, e.g. `{ done: true, proxyDisconnected: true }`, so the UI can distinguish this path from other done-only signals (e.g., stop) and apply special persistence logic only for proxy disconnects.

Either approach should ensure `session.conversationRecords` reflects the partial answer that remains visible in the UI, so Save/rehydration/persistence does not drop it.

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

}
if (port._reconnectTimerId) {
clearTimeout(port._reconnectTimerId)
port._reconnectTimerId = null
Expand Down Expand Up @@ -257,6 +268,7 @@ function setPortProxy(port, proxyTabId) {
'[background] Main port disconnected (e.g. popup/sidebar closed). Cleaning up proxy connections and listeners.',
)
port._isClosed = true
port._generating = false
if (port._reconnectTimerId) {
clearTimeout(port._reconnectTimerId)
port._reconnectTimerId = null
Expand Down Expand Up @@ -401,6 +413,7 @@ async function executeApi(session, port, config) {
console.debug('[background] Posting message to proxy tab:', { session: redactedSession })
try {
port.proxy.postMessage({ session })
port._generating = true
} catch (e) {
console.warn(
'[background] Error posting message to existing proxy tab in executeApi (ChatGPT Web Model):',
Expand All @@ -413,6 +426,7 @@ async function executeApi(session, port, config) {
console.debug('[background] Proxy re-established. Attempting to post message again.')
try {
port.proxy.postMessage({ session })
port._generating = true
console.info('[background] Successfully posted session after proxy reconnection.')
} catch (e2) {
console.error(
Expand Down
15 changes: 13 additions & 2 deletions src/components/ConversationCard/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function ConversationCard(props) {
const [session, setSession] = useState(props.session)
const windowSize = useClampWindowSize([750, 1500], [250, 1100])
const bodyRef = useRef(null)
const replacedPortRef = useRef(null)
const [completeDraggable, setCompleteDraggable] = useState(false)
const useForegroundFetch = isUsingBingWebModel(session)
const [apiModes, setApiModes] = useState([])
Expand Down Expand Up @@ -118,7 +119,7 @@ function ConversationCard(props) {

useEffect(() => {
if (props.onUpdate) props.onUpdate(port, session, conversationItemData)
}, [session, conversationItemData])
}, [port, session, conversationItemData])

useEffect(() => {
const { offsetHeight, scrollHeight, scrollTop } = bodyRef.current
Expand Down Expand Up @@ -289,6 +290,10 @@ function ConversationCard(props) {

useEffect(() => {
const portListener = () => {
if (replacedPortRef.current === port) {
replacedPortRef.current = null
return
}
Comment on lines +293 to +296

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

Instead of using a useRef (replacedPortRef) to keep track of the manually disconnected port (which can lead to a memory leak if the old port object is retained in the ref indefinitely when onDisconnect does not fire locally), you can simply attach a temporary flag directly to the port object itself (e.g., port._replaced = true).

This avoids the need for replacedPortRef entirely and prevents any potential memory leaks.

      if (port._replaced) return

setPort(Browser.runtime.connect())
setIsReady(true)
}
Expand Down Expand Up @@ -329,7 +334,7 @@ function ConversationCard(props) {
port.onMessage.removeListener(portMessageListener)
}
}
}, [conversationItemData])
}, [port, conversationItemData])

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

Including conversationItemData in the dependency array of this useEffect causes the message listener to be removed and re-registered on every single stream chunk (since conversationItemData updates continuously during generation).\n\nTo optimize performance and avoid unnecessary listener churn, you can store the latest conversationItemData in a useRef (e.g., conversationItemDataRef.current = conversationItemData on every render) and reference conversationItemDataRef.current inside portMessageListener. This allows you to safely remove conversationItemData from the useEffect dependency array so the listener is only registered once per port.

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

Including conversationItemData in the dependency array of this useEffect causes the message listener to be removed and re-added on every single streamed token, which is highly inefficient and can cause performance issues or race conditions.\n\nTo optimize this, you can remove conversationItemData from the dependency array and use a useRef to keep track of the latest conversationItemData without triggering effect re-runs.\n\nFor example, add a ref at the top of the component:\njavascript\nconst conversationItemDataRef = useRef(conversationItemData);\nconversationItemDataRef.current = conversationItemData;\n\nAnd then update portMessageListener to use conversationItemDataRef.current instead of conversationItemData.

Suggested change
}, [port, conversationItemData])
}, [port])

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

Including conversationItemData in the dependency array of this useEffect causes the message listener to be removed and re-registered on port.onMessage on every single stream chunk update (since conversationItemData changes with every new token/chunk). This is highly inefficient and can cause performance degradation or race conditions during rapid streaming.

To fix this, you can use a useRef to hold the latest portMessageListener (or the latest conversationItemData) and remove conversationItemData from the dependency array so that the listener is only registered once per port connection.

For example, you can define a ref for the listener:

const listenerRef = useRef(portMessageListener)
listenerRef.current = portMessageListener

And then in the useEffect:

useEffect(() => {
  if (useForegroundFetch) {
    return () => {}
  } else {
    const listener = (msg) => listenerRef.current(msg)
    port.onMessage.addListener(listener)
    return () => {
      port.onMessage.removeListener(listener)
    }
  }
}, [port])
Suggested change
}, [port, conversationItemData])
}, [port])


const getRetryFn = (session) => async () => {
updateAnswer(`<p class="gpt-loading">${t('Waiting for response...')}</p>`, false, 'answer')
Expand Down Expand Up @@ -493,6 +498,11 @@ function ConversationCard(props) {
text={t('Clear Conversation')}
onConfirm={async () => {
await postMessage({ stop: true })
if (!useForegroundFetch) {
replacedPortRef.current = port
port.disconnect()
setPort(Browser.runtime.connect())
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment on lines +501 to +505

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

To accompany the simplified port replacement logic and avoid memory leaks from holding onto the old port in a ref, you can set a flag directly on the port object before disconnecting.

Suggested change
if (!useForegroundFetch) {
replacedPortRef.current = port
port.disconnect()
setPort(Browser.runtime.connect())
}
if (!useForegroundFetch) {
port._replaced = true
port.disconnect()
setPort(Browser.runtime.connect())
}

Browser.runtime.sendMessage({
type: 'DELETE_CONVERSATION',
data: {
Expand All @@ -507,6 +517,7 @@ function ConversationCard(props) {
})
newSession.sessionId = session.sessionId
setSession(newSession)
setIsReady(true)
}}
/>
{!props.pageMode && (
Expand Down
13 changes: 9 additions & 4 deletions src/services/apis/azure-openai-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,15 @@ export async function generateAnswersWithAzureOpenaiApi(port, question, session)
}
},
async onStart() {},
async onEnd() {
port.postMessage({ done: true })
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
async onEnd(aborted) {
try {
if (!aborted) {
port.postMessage({ done: true })
}
} finally {
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
}
},
Comment on lines +71 to 80

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

To maintain consistency with openai-compatible-core.mjs, we should also persist the streamed partial answer into the session history when the request is aborted. We should wrap the port.postMessage call in a try...catch block in case the port is disconnected.

      async onEnd(aborted) {
        try {
          if (!aborted) {
            port.postMessage({ done: true })
          } else if (answer) {
            pushRecord(session, question, answer)
            try {
              port.postMessage({ session })
            } catch (e) {
              console.warn('[azure-openai-api] Failed to post session on abort:', e)
            }
          }
        } finally {
          port.onMessage.removeListener(messageListener)
          port.onDisconnect.removeListener(disconnectListener)
        }
      },

async onError(resp) {
port.onMessage.removeListener(messageListener)
Expand Down
81 changes: 45 additions & 36 deletions src/services/apis/chatgpt-web.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -357,42 +357,51 @@ export async function generateAnswersWithChatgptWebApi(port, question, session,
session.wsRequestId = wsRequestId
port.postMessage({ session: session })
} else {
await fetchSSE(url, {
...options,
onMessage(message) {
console.debug('sse message', message)
if (message.trim() === '[DONE]') {
finishMessage()
return
}
let data
try {
data = JSON.parse(message)
} catch (error) {
console.debug('json error', error)
return
}
handleMessage(data)
},
async onStart() {
// sendModerations(accessToken, question, session.conversationId, session.messageId)
},
async onEnd() {
port.postMessage({ done: true })
cleanController()
},
async onError(resp) {
cleanController()
if (resp instanceof Error) throw resp
if (resp.status === 403) {
throw new Error('CLOUDFLARE')
}
const error = await resp.json().catch(() => ({}))
throw new Error(
!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`,
)
},
})
try {
await fetchSSE(url, {
...options,
onMessage(message) {
console.debug('sse message', message)
if (message.trim() === '[DONE]') {
finishMessage()
return
}
let data
try {
data = JSON.parse(message)
} catch (error) {
console.debug('json error', error)
return
}
handleMessage(data)
},
async onStart() {
// sendModerations(accessToken, question, session.conversationId, session.messageId)
},
async onEnd(aborted) {
try {
if (!aborted) {
port.postMessage({ done: true })
}
} finally {
cleanController()
}
},
async onError(resp) {
cleanController()
if (resp instanceof Error) throw resp
if (resp.status === 403) {
throw new Error('CLOUDFLARE')
}
const error = await resp.json().catch(() => ({}))
throw new Error(
!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`,
)
},
})
} finally {
cleanController()
}
}

function handleMessage(data) {
Expand Down
13 changes: 9 additions & 4 deletions src/services/apis/claude-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,15 @@ export async function generateAnswersWithClaudeApi(port, question, session) {
}
},
async onStart() {},
async onEnd() {
port.postMessage({ done: true })
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
async onEnd(aborted) {
try {
if (!aborted) {
port.postMessage({ done: true })
}
} finally {
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
}
},
Comment on lines +81 to 90

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

To maintain consistency with openai-compatible-core.mjs, we should also persist the streamed partial answer into the session history when the request is aborted. We should wrap the port.postMessage call in a try...catch block in case the port is disconnected.

Suggested change
async onEnd(aborted) {
try {
if (!aborted) {
port.postMessage({ done: true })
}
} finally {
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
}
},
async onEnd(aborted) {
try {
if (!aborted) {
port.postMessage({ done: true })
} else if (answer) {
pushRecord(session, question, answer)
try {
port.postMessage({ session })
} catch (e) {
console.warn('[claude-api] Failed to post session on abort:', e)
}
}
} finally {
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
}
},

async onError(resp) {
port.onMessage.removeListener(messageListener)
Expand Down
6 changes: 5 additions & 1 deletion src/services/apis/moonshot-web.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,11 @@ export class Conversation {
}
},
async onStart() {},
async onEnd() {
async onEnd(aborted = false) {
if (aborted) {
reject(new DOMException('Aborted', 'AbortError'))
return
}
resolve({
completion: fullResponse,
})
Expand Down
28 changes: 23 additions & 5 deletions src/services/apis/openai-compatible-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,30 @@ export async function generateAnswersWithOpenAICompatible({
}
},
async onStart() {},
async onEnd() {
if (!finished) {
finish()
async onEnd(aborted = false) {
try {
if (!finished) {
if (aborted) {
const shouldPostSession = Boolean(answer) || session.isRetry
if (shouldPostSession) {
Comment on lines +149 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid reposting stale sessions after Stop/Clear

When an OpenAI-compatible stream is aborted after producing any partial text, this branch always posts the old session back to the UI. The same stop message is used by controls that immediately replace the session, such as Clear Conversation in ConversationCard; because postMessage({ stop: true }) resolves before the background abort finishes, the late { session } here can restore the just-cleared/in-flight conversation and make the next prompt include stale history even though the UI was reset.

Useful? React with 👍 / 👎.

if (answer) {
pushRecord(session, question, answer)
}
session.isRetry = false
try {
port.postMessage({ session })
} catch (e) {
console.warn('[openai-compatible-core] Failed to post session on abort:', e)
}
}
} else {
finish()
}
}
} finally {
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
}
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
},
async onError(resp) {
port.onMessage.removeListener(messageListener)
Expand Down
13 changes: 9 additions & 4 deletions src/services/apis/waylaidwanderer-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,15 @@ export async function generateAnswersWithWaylaidwandererApi(port, question, sess
}
},
async onStart() {},
async onEnd() {
port.postMessage({ done: true })
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
async onEnd(aborted) {
try {
if (!aborted) {
port.postMessage({ done: true })
}
} finally {
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
}
},
Comment on lines +66 to 75

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

To maintain consistency with openai-compatible-core.mjs, we should also persist the streamed partial answer into the session history when the request is aborted. We should wrap the port.postMessage call in a try...catch block in case the port is disconnected.

Suggested change
async onEnd(aborted) {
try {
if (!aborted) {
port.postMessage({ done: true })
}
} finally {
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
}
},
async onEnd(aborted) {
try {
if (!aborted) {
port.postMessage({ done: true })
} else if (answer) {
pushRecord(session, question, answer)
try {
port.postMessage({ session })
} catch (e) {
console.warn('[waylaidwanderer-api] Failed to post session on abort:', e)
}
}
} finally {
port.onMessage.removeListener(messageListener)
port.onDisconnect.removeListener(disconnectListener)
}
},

async onError(resp) {
port.onMessage.removeListener(messageListener)
Expand Down
6 changes: 5 additions & 1 deletion src/services/clients/claude/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,11 @@ export class Conversation {
}
},
async onStart() {},
async onEnd() {
async onEnd(aborted = false) {
if (aborted) {
reject(new DOMException('Aborted', 'AbortError'))
return
}
resolve({
completion: fullResponse,
})
Expand Down
43 changes: 31 additions & 12 deletions src/services/wrappers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,45 +66,64 @@ function isAbortError(err) {
return name === 'AbortError' || message.includes('aborted') || message.includes('aborterror')
}

function isDisconnectedPortError(err) {
if (!err || typeof err !== 'object') return false
const message = typeof err.message === 'string' ? err.message.toLowerCase() : ''
return (
message.includes('disconnected port') ||
message.includes('port closed') ||
message.includes('extension context invalidated')
)
}

export function handlePortError(session, port, err) {
if (isAbortError(err)) return
if (isDisconnectedPortError(err)) {
console.warn('[handlePortError] Ignoring disconnected port error:', err.message)
return
}
console.error(err)
const postError = (error) => {
try {
port.postMessage({ error })
} catch (postErr) {
console.warn('[handlePortError] Failed to post error:', postErr)
}
}
const message = typeof err?.message === 'string' ? err.message : ''
if (message) {
if (
['message you submitted was too long', 'maximum context length'].some((m) =>
message.includes(m),
)
)
port.postMessage({ error: t('Exceeded maximum context length') + '\n\n' + message })
postError(t('Exceeded maximum context length') + '\n\n' + message)
else if (['CaptchaChallenge', 'CAPTCHA'].some((m) => message.includes(m)))
port.postMessage({ error: t('Bing CaptchaChallenge') + '\n\n' + message })
postError(t('Bing CaptchaChallenge') + '\n\n' + message)
else if (['exceeded your current quota'].some((m) => message.includes(m)))
port.postMessage({ error: t('Exceeded quota') + '\n\n' + message })
postError(t('Exceeded quota') + '\n\n' + message)
else if (['Rate limit reached'].some((m) => message.includes(m)))
port.postMessage({ error: t('Rate limit') + '\n\n' + message })
postError(t('Rate limit') + '\n\n' + message)
else if (['authentication token has expired'].some((m) => message.includes(m)))
port.postMessage({ error: 'UNAUTHORIZED' })
postError('UNAUTHORIZED')
else if (
isUsingClaudeWebModel(session) &&
['Invalid authorization', 'Session key required'].some((m) => message.includes(m))
)
port.postMessage({
error: t('Please login at https://claude.ai first, and then click the retry button'),
})
postError(t('Please login at https://claude.ai first, and then click the retry button'))
else if (
isUsingBingWebModel(session) &&
['/turing/conversation/create: failed to parse response body.'].some((m) =>
message.includes(m),
)
)
port.postMessage({ error: t('Please login at https://bing.com first') })
else port.postMessage({ error: message })
postError(t('Please login at https://bing.com first'))
else postError(message)
} else {
const errMsg = JSON.stringify(err) ?? 'unknown error'
if (isUsingBingWebModel(session) && errMsg.includes('isTrusted'))
port.postMessage({ error: t('Please login at https://bing.com first') })
else port.postMessage({ error: errMsg })
postError(t('Please login at https://bing.com first'))
else postError(errMsg)
}
}

Expand Down
Loading