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 entrypoints/sidepanel/components/Chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ import {
ActionEvent,
Chat,
initChatSideEffects,
initTabChatSync,
} from '../../utils/chat/index'
import AttachmentSelector from '../AttachmentSelector.vue'
import CameraButton from './CameraButton.vue'
Expand Down Expand Up @@ -197,6 +198,7 @@ const chat = await Chat.getInstance()
const contextAttachmentStorage = chat.contextAttachmentStorage

initChatSideEffects()
initTabChatSync()

// Track the final assistant/agent message for each reply block (between user turns) FOR triggering retry action
const assistantActionMessageIds = computed(() => {
Expand Down
75 changes: 75 additions & 0 deletions entrypoints/sidepanel/utils/chat/side-effects.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { effectScope, watch } from 'vue'

import { ActionMessageV1 } from '@/types/chat'
import { getHostChatMap, getPageKeyFromUrl } from '@/utils/host-chat-map'
import { useGlobalI18n } from '@/utils/i18n'
import logger from '@/utils/logger'
import { lazyInitialize } from '@/utils/memo'
import { s2bRpc } from '@/utils/rpc'
import { getTabStore } from '@/utils/tab-store'
import { getUserConfig } from '@/utils/user-config'

import { Chat } from './chat'
import { welcomeMessage } from './texts'

const log = logger.child('chat-side-effects')

async function appendOrUpdateQuickActionsIfNeeded(chat: Chat) {
const { t } = await useGlobalI18n()
const userConfig = await getUserConfig()
Expand Down Expand Up @@ -74,3 +80,72 @@ async function _initChatSideEffects() {
}

export const initChatSideEffects = lazyInitialize(_initChatSideEffects)

/**
* Switch the active chat to the one associated with the given page key.
* Creates a new chat if no mapping exists or the previously mapped chat was deleted.
* Updates the page-chat-map after any switch/creation.
* Skips silently if the chat is currently answering.
*/
async function switchChatForPage(chat: Chat, pageKey: string | null): Promise<void> {
if (!pageKey) return
// Don't interrupt an in-progress generation
if (chat.isAnswering()) return
const userConfig = await getUserConfig()
const map = await getHostChatMap()
const existingChatId = map.get(pageKey)

if (existingChatId) {
if (userConfig.chat.history.currentChatId.get() === existingChatId) return
// Verify the chat still exists in storage
const chatHistory = await s2bRpc.getChatHistory(existingChatId)
if (chatHistory) {
log.debug('switchChatForPage: switching to existing chat', { pageKey, existingChatId })
await chat.switchToChat(existingChatId)
return
}
// Chat was deleted; remove stale mapping
map.delete(pageKey)
}

// No valid chat for this page — create a fresh one
log.debug('switchChatForPage: creating new chat for page', { pageKey })
const newChatId = await chat.createNewChat()
map.set(pageKey, newChatId)
}

async function _initTabChatSync() {
const chat = await Chat.getInstance()
const tabStore = await getTabStore()
const currentTabInfo = tabStore.currentTabInfo

// Sync with the active tab immediately on startup
await switchChatForPage(chat, getPageKeyFromUrl(currentTabInfo.value.url))

// Re-sync when the sidepanel becomes visible again (user reopens the panel)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
switchChatForPage(chat, getPageKeyFromUrl(currentTabInfo.value.url))
}
})

runInDetachedScope(() => {
// Switch chat when the user activates a different browser tab
watch(() => currentTabInfo.value.tabId, async (newTabId, oldTabId) => {
if (newTabId === oldTabId) return
await switchChatForPage(chat, getPageKeyFromUrl(currentTabInfo.value.url))
})

// Keep the map up-to-date when the user manually switches / creates a chat
getUserConfig().then((userConfig) => {
watch(() => userConfig.chat.history.currentChatId.get(), async (newChatId) => {
const pageKey = getPageKeyFromUrl(currentTabInfo.value.url)
if (!pageKey) return
const map = await getHostChatMap()
map.set(pageKey, newChatId)
})
})
})
}

export const initTabChatSync = lazyInitialize(_initTabChatSync)
53 changes: 53 additions & 0 deletions utils/host-chat-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { storage } from 'wxt/utils/storage'

import { debounce } from './debounce'
import { LRUCache } from './lru-cache'
import { lazyInitialize } from './memo'

const STORAGE_KEY = 'local:host-chat-map'
const MAX_SIZE = 500

/**
* Returns `origin` (protocol + hostname + port, e.g. `https://github.com`) as
* the cache key. Returns `null` for non-http(s) URLs.
*/
export function getPageKeyFromUrl(url: string | undefined): string | null {
if (!url) return null
try {
const urlObj = new URL(url)
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') return null
return urlObj.origin + urlObj.pathname
}
catch {
return null
}
}

async function _getHostChatMap() {
const cache = new LRUCache<string, string>(MAX_SIZE)

const stored = await storage.getItem<[string, string][]>(STORAGE_KEY)
if (stored) {
cache.loadEntries(stored)
}

const scheduleSave = debounce(async () => {
await storage.setItem(STORAGE_KEY, cache.entries())
}, 500)

return {
get(key: string): string | undefined {
return cache.get(key)
},
set(key: string, chatId: string): void {
cache.set(key, chatId)
scheduleSave()
},
delete(key: string): void {
cache.delete(key)
scheduleSave()
},
}
}

export const getHostChatMap = lazyInitialize(_getHostChatMap)
51 changes: 51 additions & 0 deletions utils/lru-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export class LRUCache<K, V> {
private cache: Map<K, V>
readonly maxSize: number

constructor(maxSize: number) {
this.maxSize = maxSize
this.cache = new Map()
}

get(key: K): V | undefined {
if (!this.cache.has(key)) return undefined
const value = this.cache.get(key)!
// Move to end (most recently used)
this.cache.delete(key)
this.cache.set(key, value)
return value
}

set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key)
}
else if (this.cache.size >= this.maxSize) {
// Delete the first (least recently used) item
const firstKey = this.cache.keys().next().value as K
this.cache.delete(firstKey)
}
this.cache.set(key, value)
}

delete(key: K): void {
this.cache.delete(key)
}

has(key: K): boolean {
return this.cache.has(key)
}

entries(): [K, V][] {
return Array.from(this.cache.entries())
}

loadEntries(entries: [K, V][]): void {
// Keep only the most recent maxSize entries
this.cache = new Map(entries.slice(-this.maxSize))
}

get size(): number {
return this.cache.size
}
}