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
109 changes: 95 additions & 14 deletions src/components/ConversationCard/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ function ConversationCard(props) {
const retryRecordRef = useRef(null)
const retryGenerationIdRef = useRef(0)
const requestGenerationIdRef = useRef(0)
const disposedRef = useRef(false)
const portRef = useRef(port)
const foregroundMessageListeners = useRef([])
const foregroundPortsRef = useRef(new Set())
const [completeDraggable, setCompleteDraggable] = useState(false)
const useForegroundFetch = isUsingBingWebModel(session)
const [apiModes, setApiModes] = useState([])
Expand Down Expand Up @@ -108,6 +112,35 @@ function ConversationCard(props) {
? '-1'
: UNMATCHED_API_MODE_VALUE

const disposeOwnedTransports = () => {
if (disposedRef.current) return
disposedRef.current = true
requestGenerationIdRef.current += 1
retryGenerationIdRef.current += 1

const foregroundPorts = Array.from(foregroundPortsRef.current)
foregroundPortsRef.current.clear()
for (const foregroundPort of foregroundPorts) foregroundPort.disconnect()
foregroundMessageListeners.current = []

try {
portRef.current?.disconnect()
} catch (e) {
// The runtime Port may already be disconnected.
}
}

useLayoutEffect(() => {
portRef.current = port
}, [port])

useLayoutEffect(() => {
disposedRef.current = false
return () => {
disposeOwnedTransports()
}
}, [])

useLayoutEffect(() => {
if (session.conversationRecords.length === 0) {
if (props.question && triggered)
Expand Down Expand Up @@ -189,6 +222,7 @@ function ConversationCard(props) {
}

const portMessageListener = (msg) => {
if (disposedRef.current) return
if (isSupersededRequestMessage(msg, requestGenerationIdRef.current)) return
if (isSupersededGenerationMessage(msg, retryGenerationIdRef.current)) return

Expand Down Expand Up @@ -278,40 +312,74 @@ function ConversationCard(props) {
}
}

const foregroundMessageListeners = useRef([])

/**
* @param {Session|undefined} session
* @param {boolean|undefined} stop
* @param {number|undefined} stopGenerationId
*/
const postMessage = async ({ session, stop, stopGenerationId }) => {
if (disposedRef.current) return
const requestGenerationId = session ? ++requestGenerationIdRef.current : undefined
if (useForegroundFetch) {
foregroundMessageListeners.current.forEach((listener) =>
listener({ session, stop, stopGenerationId, requestGenerationId }),
)
for (const listener of [...foregroundMessageListeners.current]) {
listener({ session, stop, stopGenerationId, requestGenerationId })
}
if (session) {
let disconnected = false
const messageListeners = new Set()
const disconnectListeners = new Set()
const removeForegroundMessageListener = (listener) => {
const index = foregroundMessageListeners.current.indexOf(listener)
if (index !== -1) foregroundMessageListeners.current.splice(index, 1)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const fakePort = {
postMessage: (msg) => {
if (disconnected || disposedRef.current) return
portMessageListener({ ...msg, requestGenerationId })
},
onMessage: {
addListener: (listener) => {
if (disconnected) return
messageListeners.add(listener)
foregroundMessageListeners.current.push(listener)
},
removeListener: (listener) => {
const index = foregroundMessageListeners.current.indexOf(listener)
if (index !== -1) foregroundMessageListeners.current.splice(index, 1)
messageListeners.delete(listener)
removeForegroundMessageListener(listener)
},
},
onDisconnect: {
addListener: () => {},
removeListener: () => {},
addListener: (listener) => {
if (disconnected) {
listener()
return
}
disconnectListeners.add(listener)
},
removeListener: (listener) => {
disconnectListeners.delete(listener)
},
},
disconnect: () => {
if (disconnected) return
disconnected = true
for (const listener of messageListeners) removeForegroundMessageListener(listener)
messageListeners.clear()
const listeners = Array.from(disconnectListeners)
disconnectListeners.clear()
for (const listener of listeners) {
try {
listener()
} catch (error) {
console.warn('[ConversationCard] Foreground disconnect listener failed:', error)
}
}
},
}
foregroundPortsRef.current.add(fakePort)
try {
const bingToken = (await getUserConfig()).bingAccessToken
if (disposedRef.current || disconnected) return
if (isUsingModelName('bingFreeSydney', session))
await generateAnswersWithBingWebApi(
fakePort,
Expand All @@ -322,7 +390,10 @@ function ConversationCard(props) {
)
else await generateAnswersWithBingWebApi(fakePort, session.question, session, bingToken)
} catch (err) {
handlePortError(session, fakePort, err, t)
if (!disposedRef.current && !disconnected) handlePortError(session, fakePort, err, t)
} finally {
fakePort.disconnect()
Comment thread
PeterDaveHello marked this conversation as resolved.
Comment thread
pullfrog[bot] marked this conversation as resolved.
foregroundPortsRef.current.delete(fakePort)
}
}
} else {
Expand All @@ -343,13 +414,17 @@ function ConversationCard(props) {
replacedPortRef.current = null
return
}
setPort(Browser.runtime.connect())
if (disposedRef.current) return
const nextPort = Browser.runtime.connect()
portRef.current = nextPort
setPort(nextPort)
setIsReady(true)
}

const closeChatsMessageListener = (message) => {
if (message.type === 'CLOSE_CHATS') {
port.disconnect()
if (props.onClose) disposeOwnedTransports()
else port.disconnect()
Browser.runtime.onMessage.removeListener(closeChatsMessageListener)
window.removeEventListener('keydown', closeChatsEscListener)
if (props.onClose) props.onClose()
Expand Down Expand Up @@ -443,7 +518,8 @@ function ConversationCard(props) {
className="gpt-util-icon"
title={t('Close the Window')}
onClick={() => {
port.disconnect()
if (props.onClose) disposeOwnedTransports()
else port.disconnect()
if (props.onClose) props.onClose()
}}
>
Expand Down Expand Up @@ -567,10 +643,13 @@ function ConversationCard(props) {
error,
)
}
if (disposedRef.current) return
if (!useForegroundFetch) {
replacedPortRef.current = port
port.disconnect()
setPort(Browser.runtime.connect())
const nextPort = Browser.runtime.connect()
portRef.current = nextPort
setPort(nextPort)
}
partialAnswerRef.current = ''
retryRecordRef.current = null
Expand Down Expand Up @@ -708,8 +787,10 @@ function ConversationCard(props) {
try {
await postMessage({ session: newSession })
} catch (e) {
if (disposedRef.current) return
updateAnswer(e, false, 'error')
}
if (disposedRef.current || !bodyRef.current) return
bodyRef.current.scrollTo({
top: bodyRef.current.scrollHeight,
behavior: 'instant',
Expand Down
145 changes: 145 additions & 0 deletions tests/setup/conversation-card-lifecycle-loader-hooks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'

const stubs = new Map([
['../InputBox', 'test:conversation-input-box'],
['../ConversationItem', 'test:conversation-item'],
['../../utils', 'test:conversation-utils'],
['@primer/octicons-react', 'test:conversation-primer-icons'],
['react-bootstrap-icons', 'test:conversation-bootstrap-icons'],
['file-saver', 'test:conversation-file-saver'],
['../FloatingToolbar', 'test:conversation-floating-toolbar'],
['../../hooks/use-clamp-window-size', 'test:conversation-window-size'],
['../../config/index.mjs', 'test:conversation-config'],
['react-i18next', 'test:conversation-i18n'],
['../DeleteButton', 'test:conversation-delete-button'],
['../../hooks/use-config.mjs', 'test:conversation-use-config'],
['../../services/local-session.mjs', 'test:conversation-local-session'],
['uuid', 'test:conversation-uuid'],
['../../services/init-session.mjs', 'test:conversation-init-session'],
['lodash-es', 'test:conversation-lodash'],
['../../services/apis/bing-web.mjs', 'test:conversation-bing'],
['../../services/wrappers.mjs', 'test:conversation-wrappers'],
['../../popup/sections/api-modes-provider-utils.mjs', 'test:conversation-provider-utils'],
['../../utils/error-text.mjs', 'test:conversation-error-text'],
['webextension-polyfill', 'test:conversation-browser'],
])

const iconExports = `
export const ArchiveIcon = () => null
export const DesktopDownloadIcon = () => null
export const LinkExternalIcon = () => null
export const MoveToBottomIcon = () => null
export const SearchIcon = () => null
`

const sources = {
'test:conversation-input-box': `
export default function InputBox(props) {
globalThis.__CONVERSATION_LIFECYCLE_TEST__.inputBoxProps = props
return null
}
`,
'test:conversation-item': 'export default function ConversationItem() { return null }',
'test:conversation-utils': `
export const apiModeToModelName = () => 'test-model'
export const createElementAtPosition = () => document.createElement('div')
export const getApiModesFromConfig = () => []
export const getUniquelySelectedApiModeIndex = () => -1
export const isFirefox = () => false
export const isMobile = () => false
export const isSafari = () => false
export const isUsingModelName = () => false
export const modelNameToDesc = () => 'Test Model'
`,
'test:conversation-primer-icons': iconExports,
'test:conversation-bootstrap-icons': `
export const Pin = () => null
export const WindowDesktop = () => null
export const XLg = () => null
`,
'test:conversation-file-saver': 'export default { saveAs() {} }',
'test:conversation-floating-toolbar': 'export default function FloatingToolbar() { return null }',
'test:conversation-window-size': 'export const useClampWindowSize = () => [1000, 1000]',
'test:conversation-config': `
export const getUserConfig = () => globalThis.__CONVERSATION_LIFECYCLE_TEST__.getUserConfig()
export const isUsingBingWebModel = () => globalThis.__CONVERSATION_LIFECYCLE_TEST__.foreground
export const Models = { customModel: { desc: 'Custom Model' } }
`,
'test:conversation-i18n': 'export const useTranslation = () => ({ t: (value) => value })',
'test:conversation-delete-button': `
export default function DeleteButton(props) {
globalThis.__CONVERSATION_LIFECYCLE_TEST__.deleteButtonProps = props
return null
}
`,
'test:conversation-use-config': `
export const useConfig = () => globalThis.__CONVERSATION_LIFECYCLE_TEST__.config
`,
'test:conversation-local-session': 'export const createSession = async () => {}',
'test:conversation-uuid': "export const v4 = () => 'test-session-id'",
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
'test:conversation-init-session': `
export const initSession = (session) => ({ conversationRecords: [], ...session })
`,
'test:conversation-lodash': `
export const findLastIndex = (array, predicate) => {
for (let index = array.length - 1; index >= 0; --index) {
if (predicate(array[index])) return index
}
return -1
}
`,
'test:conversation-bing': `
export const generateAnswersWithBingWebApi = (...args) =>
globalThis.__CONVERSATION_LIFECYCLE_TEST__.generateAnswers(...args)
`,
'test:conversation-wrappers': 'export const handlePortError = () => {}',
'test:conversation-provider-utils': `
export const getApiModeDisplayLabel = () => ''
export const getConversationAiName = () => 'Test AI'
`,
'test:conversation-error-text': 'export const getDisplayErrorText = (value) => String(value)',
'test:conversation-browser': `
const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__
export default {
runtime: {
connect: () => state.createPort(),
getURL: (path) => 'chrome-extension://test/' + path,
sendMessage: async () => {},
onMessage: state.runtimeOnMessage,
},
}
`,
}

export async function resolve(specifier, context, nextResolve) {
if (context.parentURL?.endsWith('/src/components/ConversationCard/index.jsx')) {
const stubUrl = stubs.get(specifier)
if (stubUrl) return { url: stubUrl, shortCircuit: true }
}

return nextResolve(specifier, context)
}

export async function load(url, context, nextLoad) {
if (url.startsWith('test:conversation-')) {
return {
shortCircuit: true,
format: 'module',
source: sources[url],
}
}

if (url.startsWith('file://') && url.endsWith('.jsx') && !url.includes('node_modules')) {
const source = await readFile(fileURLToPath(url), 'utf8')
const esbuild = await import('esbuild')
const result = await esbuild.transform(source, {
loader: 'jsx',
jsx: 'automatic',
jsxImportSource: 'preact',
})
return { shortCircuit: true, format: 'module', source: result.code }
}

return nextLoad(url, context)
}
Loading