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
28 changes: 27 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,10 @@ import {
getThreadTerminalQuickCommands,
getThreadTerminalStatus,
getWorkspaceRootsState,
getDirectoryComposioStatus,
listDirectoryApps,
listDirectoryComposioConnectors,
listDirectoryPlugins,
listLocalDirectories,
openProjectRoot,
persistFirstLaunchPluginsCardPreference,
Expand Down Expand Up @@ -1204,6 +1208,7 @@ type DirectoryTryItemPayload = {
displayName: string
skillPath?: string
prompt?: string
tryKey?: string
attachedSkills?: Array<{ name: string; path: string }>
}

Expand Down Expand Up @@ -2010,6 +2015,9 @@ onMounted(() => {
void loadFreeModeStatus()
void refreshThreadTerminalStatus()
void refreshTerminalQuickCommands()
window.setTimeout(() => {
void preloadDirectoryCatalogs()
}, 1500)
Comment on lines +2018 to +2020
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clear the scheduled preload timer on unmount.

Line 2018 schedules a timeout but the handle is not retained, so the callback can still fire after unmount and issue background requests from a stale lifecycle.

🧩 Proposed fix
@@
 let threadSearchTimer: ReturnType<typeof setTimeout> | null = null
 let terminalKeyboardFocusFallbackTimer: ReturnType<typeof setTimeout> | null = null
+let preloadDirectoryCatalogsTimer: ReturnType<typeof setTimeout> | null = null
@@
-  window.setTimeout(() => {
+  preloadDirectoryCatalogsTimer = window.setTimeout(() => {
     void preloadDirectoryCatalogs()
   }, 1500)
@@
   if (threadSearchTimer) {
     clearTimeout(threadSearchTimer)
     threadSearchTimer = null
   }
+  if (preloadDirectoryCatalogsTimer) {
+    clearTimeout(preloadDirectoryCatalogsTimer)
+    preloadDirectoryCatalogsTimer = null
+  }
   clearTerminalKeyboardFocusFallbackTimer()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/App.vue` around lines 2018 - 2020, Store the timeout handle returned by
window.setTimeout when scheduling preloadDirectoryCatalogs (e.g., const
preloadTimer = window.setTimeout(...)) and register a Vue unmount hook
(onBeforeUnmount or beforeUnmount in the component setup) that calls
clearTimeout(preloadTimer) to cancel the scheduled callback; this ensures
preloadDirectoryCatalogs cannot run after the component has unmounted.

})

watch(visibleFeedbackErrors, (values, oldValues) => {
Expand Down Expand Up @@ -4154,6 +4162,24 @@ function onSelectCollaborationMode(mode: 'default' | 'plan'): void {
setSelectedCollaborationMode(mode)
}

async function preloadDirectoryCatalogs(): Promise<void> {
const cwd = directoryCwd.value.trim()
const threadId = routeThreadId.value.trim()
try {
const composioStatusPromise = getDirectoryComposioStatus()
await Promise.allSettled([
listDirectoryPlugins(cwd ? [cwd] : undefined),
listDirectoryApps(threadId || undefined),
composioStatusPromise.then(async (status) => {
if (!status.available || !status.authenticated) return
await listDirectoryComposioConnectors('', null, 50)
}),
])
} catch {
// Background preloading should never block normal navigation.
}
}

async function initialize(): Promise<void> {
await router.isReady()

Expand Down Expand Up @@ -4453,7 +4479,7 @@ function buildDirectoryTryPrompt(payload: DirectoryTryItemPayload): string {
}

function getDirectoryTryItemKey(payload: DirectoryTryItemPayload): string {
return `${payload.kind}:${payload.name}:${payload.skillPath ?? ''}`
return payload.tryKey ?? `${payload.kind}:${payload.name}:${payload.skillPath ?? ''}:${payload.prompt ?? ''}`
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against blank tryKey values in dedupe key generation.

Line 4482 uses ??, so an empty string tryKey is treated as a valid key. That can break the in-flight guard (directoryTryInFlightKey) behavior for rapid repeated clicks.

🧩 Proposed fix
 function getDirectoryTryItemKey(payload: DirectoryTryItemPayload): string {
-  return payload.tryKey ?? `${payload.kind}:${payload.name}:${payload.skillPath ?? ''}:${payload.prompt ?? ''}`
+  const explicitTryKey = payload.tryKey?.trim()
+  return explicitTryKey && explicitTryKey.length > 0
+    ? explicitTryKey
+    : `${payload.kind}:${payload.name}:${payload.skillPath ?? ''}:${payload.prompt ?? ''}`
 }
📝 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
return payload.tryKey ?? `${payload.kind}:${payload.name}:${payload.skillPath ?? ''}:${payload.prompt ?? ''}`
function getDirectoryTryItemKey(payload: DirectoryTryItemPayload): string {
const explicitTryKey = payload.tryKey?.trim()
return explicitTryKey && explicitTryKey.length > 0
? explicitTryKey
: `${payload.kind}:${payload.name}:${payload.skillPath ?? ''}:${payload.prompt ?? ''}`
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/App.vue` at line 4482, The dedupe key generation currently uses the
nullish coalescing on payload.tryKey so empty strings are accepted; change the
expression in the return statement that builds the key (the payload.tryKey ??
...) to treat blank/whitespace tryKey as absent by checking
payload.tryKey?.trim() and using it only when non-empty, e.g. use
(payload.tryKey?.trim() ? payload.tryKey : `<rest of template>`), so
directoryTryInFlightKey won’t receive empty-string keys.

}

async function onTryDirectoryItem(payload: DirectoryTryItemPayload): Promise<void> {
Expand Down
203 changes: 160 additions & 43 deletions src/api/codexGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2103,27 +2103,86 @@ function normalizeDirectoryMcpServer(value: unknown): DirectoryMcpServerStatus |
}
}

export async function listDirectoryPlugins(cwds?: string[]): Promise<DirectoryPluginSummary[]> {
type DirectoryCacheOptions = { force?: boolean }

const directoryPluginCache = new Map<string, { promise: Promise<DirectoryPluginSummary[]> | null; value: DirectoryPluginSummary[] | null }>()
const directoryAppCache = new Map<string, { promise: Promise<DirectoryAppInfo[]> | null; value: DirectoryAppInfo[] | null }>()
const directoryComposioConnectorCache = new Map<string, { promise: Promise<DirectoryComposioConnectorPage> | null; value: DirectoryComposioConnectorPage | null }>()
let directoryComposioStatusCache: { promise: Promise<DirectoryComposioStatus> | null; value: DirectoryComposioStatus | null } = { promise: null, value: null }
Comment on lines +2108 to +2111
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bound cache growth to avoid long-session memory bloat.

These Maps are unbounded, and directoryComposioConnectorCache keys include free-form query/cursor/limit. In active sessions (search typing, pagination), entries can accumulate indefinitely.

💡 Suggested direction (bounded cache helper)
+const DIRECTORY_CACHE_MAX_ENTRIES = 200
+
+function setBoundedCacheEntry<K, V>(map: Map<K, V>, key: K, value: V): void {
+  if (map.has(key)) map.delete(key)
+  map.set(key, value)
+  if (map.size > DIRECTORY_CACHE_MAX_ENTRIES) {
+    const oldestKey = map.keys().next().value as K | undefined
+    if (oldestKey !== undefined) map.delete(oldestKey)
+  }
+}

Then replace map.set(...) calls in these cache paths with setBoundedCacheEntry(...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/codexGateway.ts` around lines 2108 - 2111, The Maps
directoryPluginCache, directoryAppCache and directoryComposioConnectorCache are
unbounded and can grow indefinitely (especially directoryComposioConnectorCache
because keys include free-form query/cursor/limit); add a bounded cache helper
(e.g., setBoundedCacheEntry) that accepts a Map, a normalized key, the
value/promise and a maxSize, implements simple eviction (LRU or FIFO) when size
> maxSize, and use it to replace direct map.set(...) calls for
directoryPluginCache, directoryAppCache and directoryComposioConnectorCache;
also normalize keys for directoryComposioConnectorCache (strip or canonicalize
cursor/limit/query params) before storing so ephemeral pagination keys don’t
bloat the cache, and keep directoryComposioStatusCache as a single-entry object
or wrap it with the same helper if desired.


function directoryPluginsCacheKey(cwds?: string[]): string {
return (cwds ?? []).map((cwd) => cwd.trim()).filter(Boolean).join('\n')
}

function cloneDirectoryPluginRows(rows: DirectoryPluginSummary[]): DirectoryPluginSummary[] {
return rows.map((row) => ({ ...row, capabilities: [...row.capabilities], defaultPrompt: [...row.defaultPrompt], screenshotUrls: [...row.screenshotUrls], screenshots: [...row.screenshots] }))
}

function cloneDirectoryAppRows(rows: DirectoryAppInfo[]): DirectoryAppInfo[] {
return rows.map((row) => ({ ...row, pluginDisplayNames: [...row.pluginDisplayNames] }))
}

function cloneDirectoryComposioConnectorRows(rows: DirectoryComposioConnector[]): DirectoryComposioConnector[] {
return rows.map((row) => ({
...row,
authModes: [...row.authModes],
connectionStatuses: [...row.connectionStatuses],
}))
}

function cloneDirectoryComposioConnectorPage(page: DirectoryComposioConnectorPage): DirectoryComposioConnectorPage {
return {
data: cloneDirectoryComposioConnectorRows(page.data),
nextCursor: page.nextCursor,
total: page.total,
}
}

function clearDirectoryCatalogCaches(): void {
directoryPluginCache.clear()
directoryAppCache.clear()
directoryComposioConnectorCache.clear()
directoryComposioStatusCache = { promise: null, value: null }
}

export async function listDirectoryPlugins(cwds?: string[], options: DirectoryCacheOptions = {}): Promise<DirectoryPluginSummary[]> {
const cacheKey = directoryPluginsCacheKey(cwds)
const cached = directoryPluginCache.get(cacheKey)
if (!options.force && cached?.value) return cloneDirectoryPluginRows(cached.value)
if (!options.force && cached?.promise) return cloneDirectoryPluginRows(await cached.promise)

const params: Record<string, unknown> = {}
if (cwds && cwds.length > 0) params.cwds = cwds
const payload = await callRpc<{ marketplaces?: unknown[] }>('plugin/list', params)
const plugins: DirectoryPluginSummary[] = []
for (const marketplaceValue of payload.marketplaces ?? []) {
const marketplace = asRecord(marketplaceValue)
if (!marketplace) continue
const iface = asRecord(marketplace.interface)
const meta = {
name: readString(marketplace.name) ?? '',
displayName: readString(iface?.displayName ?? iface?.display_name) ?? '',
path: readString(marketplace.path),
const request = (async () => {
const payload = await callRpc<{ marketplaces?: unknown[] }>('plugin/list', params)
const plugins: DirectoryPluginSummary[] = []
for (const marketplaceValue of payload.marketplaces ?? []) {
const marketplace = asRecord(marketplaceValue)
if (!marketplace) continue
const iface = asRecord(marketplace.interface)
const meta = {
name: readString(marketplace.name) ?? '',
displayName: readString(iface?.displayName ?? iface?.display_name) ?? '',
path: readString(marketplace.path),
}
const rows = Array.isArray(marketplace.plugins) ? marketplace.plugins : []
for (const row of rows) {
const plugin = normalizeDirectoryPluginSummary(row, meta)
if (plugin) plugins.push(plugin)
}
}
const rows = Array.isArray(marketplace.plugins) ? marketplace.plugins : []
for (const row of rows) {
const plugin = normalizeDirectoryPluginSummary(row, meta)
if (plugin) plugins.push(plugin)
directoryPluginCache.set(cacheKey, { promise: null, value: cloneDirectoryPluginRows(plugins) })
return plugins
})()
directoryPluginCache.set(cacheKey, { promise: request, value: options.force ? null : cached?.value ?? null })
try {
return cloneDirectoryPluginRows(await request)
} catch (error) {
if (directoryPluginCache.get(cacheKey)?.promise === request) {
directoryPluginCache.delete(cacheKey)
}
throw error
}
return plugins
}

export async function readDirectoryPlugin(plugin: DirectoryPluginSummary): Promise<DirectoryPluginDetail> {
Expand Down Expand Up @@ -2156,6 +2215,7 @@ export async function installDirectoryPlugin(plugin: DirectoryPluginSummary): Pr
if (plugin.marketplacePath) params.marketplacePath = plugin.marketplacePath
if (plugin.remoteMarketplaceName) params.remoteMarketplaceName = plugin.remoteMarketplaceName
const payload = await callRpc<{ authPolicy?: string; auth_policy?: string; appsNeedingAuth?: unknown[]; apps_needing_auth?: unknown[] }>('plugin/install', params)
clearDirectoryCatalogCaches()
const apps = payload.appsNeedingAuth ?? payload.apps_needing_auth ?? []
return {
authPolicy: readString(payload.authPolicy ?? payload.auth_policy) ?? '',
Expand All @@ -2165,6 +2225,7 @@ export async function installDirectoryPlugin(plugin: DirectoryPluginSummary): Pr

export async function uninstallDirectoryPlugin(pluginId: string): Promise<void> {
await callRpc('plugin/uninstall', { pluginId })
clearDirectoryCatalogCaches()
}

export async function setDirectoryPluginEnabled(pluginId: string, enabled: boolean): Promise<void> {
Expand All @@ -2174,25 +2235,43 @@ export async function setDirectoryPluginEnabled(pluginId: string, enabled: boole
expectedVersion: null,
reloadUserConfig: true,
})
}

export async function listDirectoryApps(threadId?: string): Promise<DirectoryAppInfo[]> {
const apps: DirectoryAppInfo[] = []
let cursor: string | null = null
let catalogRank = 0
do {
const params: Record<string, unknown> = { limit: 100 }
if (cursor) params.cursor = cursor
if (threadId) params.threadId = threadId
const payload = await callRpc<{ data?: unknown[]; nextCursor?: string | null; next_cursor?: string | null }>('app/list', params)
for (const item of payload.data ?? []) {
const app = normalizeDirectoryApp(item, catalogRank)
if (app) apps.push(app)
catalogRank += 1
directoryPluginCache.clear()
}

export async function listDirectoryApps(threadId?: string, options: DirectoryCacheOptions = {}): Promise<DirectoryAppInfo[]> {
const cacheKey = threadId?.trim() ?? ''
const cached = directoryAppCache.get(cacheKey)
if (!options.force && cached?.value) return cloneDirectoryAppRows(cached.value)
if (!options.force && cached?.promise) return cloneDirectoryAppRows(await cached.promise)

const request = (async () => {
const apps: DirectoryAppInfo[] = []
let cursor: string | null = null
let catalogRank = 0
do {
const params: Record<string, unknown> = { limit: 100 }
if (cursor) params.cursor = cursor
if (cacheKey) params.threadId = cacheKey
const payload = await callRpc<{ data?: unknown[]; nextCursor?: string | null; next_cursor?: string | null }>('app/list', params)
for (const item of payload.data ?? []) {
const app = normalizeDirectoryApp(item, catalogRank)
if (app) apps.push(app)
catalogRank += 1
}
cursor = readString(payload.nextCursor ?? payload.next_cursor)
} while (cursor)
directoryAppCache.set(cacheKey, { promise: null, value: cloneDirectoryAppRows(apps) })
return apps
})()
directoryAppCache.set(cacheKey, { promise: request, value: options.force ? null : cached?.value ?? null })
try {
return cloneDirectoryAppRows(await request)
} catch (error) {
if (directoryAppCache.get(cacheKey)?.promise === request) {
directoryAppCache.delete(cacheKey)
}
cursor = readString(payload.nextCursor ?? payload.next_cursor)
} while (cursor)
return apps
throw error
}
}

export async function setDirectoryAppEnabled(appId: string, enabled: boolean): Promise<void> {
Expand All @@ -2202,6 +2281,7 @@ export async function setDirectoryAppEnabled(appId: string, enabled: boolean): P
expectedVersion: null,
reloadUserConfig: true,
})
directoryAppCache.clear()
}

export async function listDirectoryMcpServers(): Promise<DirectoryMcpServerStatus[]> {
Expand Down Expand Up @@ -2231,34 +2311,71 @@ export async function startDirectoryMcpLogin(name: string): Promise<DirectoryMcp
}
}

export async function getDirectoryComposioStatus(): Promise<DirectoryComposioStatus> {
const response = await fetch('/codex-api/composio/status')
if (!response.ok) {
throw new Error(`Failed to load Composio status (${response.status})`)
export async function getDirectoryComposioStatus(options: DirectoryCacheOptions = {}): Promise<DirectoryComposioStatus> {
if (!options.force && directoryComposioStatusCache.value) return { ...directoryComposioStatusCache.value }
if (!options.force && directoryComposioStatusCache.promise) return { ...await directoryComposioStatusCache.promise }
const request = (async () => {
const response = await fetch('/codex-api/composio/status')
if (!response.ok) {
throw new Error(`Failed to load Composio status (${response.status})`)
}
const status = await response.json() as DirectoryComposioStatus
directoryComposioStatusCache = { promise: null, value: { ...status } }
return status
})()
directoryComposioStatusCache = { promise: request, value: options.force ? null : directoryComposioStatusCache.value }
try {
return { ...await request }
} catch (error) {
if (directoryComposioStatusCache.promise === request) {
directoryComposioStatusCache = { promise: null, value: null }
}
throw error
}
return await response.json() as DirectoryComposioStatus
}

export async function listDirectoryComposioConnectors(
query = '',
cursor: string | null = null,
limit = 50,
options: DirectoryCacheOptions = {},
): Promise<DirectoryComposioConnectorPage> {
const normalizedQuery = query.trim()
const normalizedCursor = cursor?.trim() || ''
const normalizedLimit = limit && Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 50
const cacheKey = `${normalizedQuery}\n${normalizedCursor}\n${normalizedLimit}`
const cached = directoryComposioConnectorCache.get(cacheKey)
if (!options.force && cached?.value) return cloneDirectoryComposioConnectorPage(cached.value)
if (!options.force && cached?.promise) return cloneDirectoryComposioConnectorPage(await cached.promise)

const request = (async () => {
const params = new URLSearchParams()
if (query.trim()) params.set('query', query.trim())
if (cursor) params.set('cursor', cursor)
if (limit && Number.isFinite(limit)) params.set('limit', String(Math.max(1, Math.floor(limit))))
if (normalizedQuery) params.set('query', normalizedQuery)
if (normalizedCursor) params.set('cursor', normalizedCursor)
params.set('limit', String(normalizedLimit))
const suffix = params.toString()
const response = await fetch(`/codex-api/composio/connectors${suffix ? `?${suffix}` : ''}`)
if (!response.ok) {
throw new Error(`Failed to list Composio connectors (${response.status})`)
}
const payload = await response.json() as DirectoryComposioConnectorPage | { data?: DirectoryComposioConnector[]; nextCursor?: string | null; total?: number }
return {
const page = {
data: Array.isArray(payload.data) ? payload.data : [],
nextCursor: typeof payload.nextCursor === 'string' && payload.nextCursor.length > 0 ? payload.nextCursor : null,
total: typeof payload.total === 'number' && Number.isFinite(payload.total) ? Math.max(0, Math.floor(payload.total)) : 0,
}
directoryComposioConnectorCache.set(cacheKey, { promise: null, value: cloneDirectoryComposioConnectorPage(page) })
return page
})()
directoryComposioConnectorCache.set(cacheKey, { promise: request, value: options.force ? null : cached?.value ?? null })
try {
return cloneDirectoryComposioConnectorPage(await request)
} catch (error) {
if (directoryComposioConnectorCache.get(cacheKey)?.promise === request) {
directoryComposioConnectorCache.delete(cacheKey)
}
throw error
}
}

export async function readDirectoryComposioConnector(slug: string): Promise<DirectoryComposioConnectorDetail> {
Expand Down
Loading