@@ -234,8 +263,10 @@
:cwd="composerCwd"
:collaboration-modes="availableCollaborationModes"
:selected-collaboration-mode="selectedCollaborationMode"
- :models="availableModelIds" :selected-model="selectedModelId"
+ :models="availableModels" :selected-model="selectedModelId"
:selected-reasoning-effort="selectedReasoningEffort" :skills="installedSkills"
+ :plugins="composerPluginOptions"
+ :apps="composerAppOptions"
:thread-token-usage="selectedThreadTokenUsage"
:codex-quota="codexQuota"
:is-turn-in-progress="false"
@@ -276,9 +307,11 @@
:cwd="composerCwd"
:collaboration-modes="availableCollaborationModes"
:selected-collaboration-mode="selectedCollaborationMode"
- :models="availableModelIds"
+ :models="availableModels"
:selected-model="selectedModelId" :selected-reasoning-effort="selectedReasoningEffort"
:skills="installedSkills"
+ :plugins="composerPluginOptions"
+ :apps="composerAppOptions"
:thread-token-usage="selectedThreadTokenUsage"
:codex-quota="codexQuota"
:is-turn-in-progress="isSelectedThreadInProgress" :is-interrupting-turn="isInterruptingTurn"
@@ -308,6 +341,7 @@ import ThreadComposer from './components/content/ThreadComposer.vue'
import QueuedMessages from './components/content/QueuedMessages.vue'
import ComposerDropdown from './components/content/ComposerDropdown.vue'
import ComposerRuntimeDropdown from './components/content/ComposerRuntimeDropdown.vue'
+import FileExplorer from './components/content/FileExplorer.vue'
import SidebarThreadControls from './components/sidebar/SidebarThreadControls.vue'
import IconTablerSearch from './components/icons/IconTablerSearch.vue'
import IconTablerSettings from './components/icons/IconTablerSettings.vue'
@@ -319,6 +353,9 @@ import {
getAccounts,
getHomeDirectory,
getProjectRootSuggestion,
+ getRuntimeInfo,
+ listApps,
+ listPlugins,
getWorkspaceRootsState,
openProjectRoot,
removeAccount,
@@ -326,11 +363,14 @@ import {
searchThreads,
switchAccount,
} from './api/codexGateway'
-import type { ReasoningEffort, ThreadScrollState, UiAccountEntry, UiRateLimitWindow } from './types/codex'
+import type { ReasoningEffort, ThreadScrollState, UiAccountEntry, UiRateLimitSnapshot, UiRateLimitWindow } from './types/codex'
+import { buildFilesRouteLocation } from './utils/fileExplorer'
const ThreadConversation = defineAsyncComponent(() => import('./components/content/ThreadConversation.vue'))
const ReviewPane = defineAsyncComponent(() => import('./components/content/ReviewPane.vue'))
const SkillsHub = defineAsyncComponent(() => import('./components/content/SkillsHub.vue'))
+const PluginsHub = defineAsyncComponent(() => import('./components/content/PluginsHub.vue'))
+const AutomationsHub = defineAsyncComponent(() => import('./components/content/AutomationsHub.vue'))
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'codex-web-local.sidebar-collapsed.v1'
const LAST_ACTIVE_THREAD_ROUTE_STORAGE_KEY = 'codex-web-local.last-active-thread-route.v1'
@@ -348,7 +388,7 @@ const {
selectedThreadTokenUsage,
selectedThreadId,
availableCollaborationModes,
- availableModelIds,
+ availableModels,
selectedCollaborationMode,
selectedModelId,
selectedReasoningEffort,
@@ -407,6 +447,7 @@ const serverMatchedThreadIds = ref
(null)
let threadSearchTimer: ReturnType | null = null
const defaultNewProjectName = ref('New Project (1)')
const homeDirectory = ref('')
+const codexCliVersion = ref('unknown')
const isSettingsOpen = ref(false)
const isReviewPaneOpen = ref(false)
const accounts = ref([])
@@ -430,11 +471,21 @@ const mobileResumeReloadTriggered = ref(false)
const mobileResumeSyncInProgress = ref(false)
let accountStatePollTimer: number | null = null
let isAccountStatePollInFlight = false
+const composerPluginOptions = ref>([])
+const composerAppOptions = ref>([])
const routeThreadId = computed(() => {
const rawThreadId = route.params.threadId
return typeof rawThreadId === 'string' ? rawThreadId : ''
})
+const routeFilePath = computed(() => {
+ const rawPath = route.query.path
+ return typeof rawPath === 'string' ? rawPath : ''
+})
+const routeFileCwd = computed(() => {
+ const rawCwd = route.query.cwd
+ return typeof rawCwd === 'string' ? rawCwd : ''
+})
const knownThreadIdSet = computed(() => {
const ids = new Set()
@@ -447,9 +498,15 @@ const knownThreadIdSet = computed(() => {
})
const isHomeRoute = computed(() => route.name === 'home')
+const isPluginsRoute = computed(() => route.name === 'plugins')
+const isAutomationsRoute = computed(() => route.name === 'automations')
const isSkillsRoute = computed(() => route.name === 'skills')
+const isFilesRoute = computed(() => route.name === 'files')
const contentTitle = computed(() => {
+ if (isPluginsRoute.value) return 'Plugins'
+ if (isAutomationsRoute.value) return 'Automations'
if (isSkillsRoute.value) return 'Skills'
+ if (isFilesRoute.value) return getPathLeafName(routeFilePath.value) || 'Files'
if (isHomeRoute.value) return 'New thread'
return selectedThread.value?.title ?? 'Choose a thread'
})
@@ -520,6 +577,7 @@ onMounted(() => {
void initialize()
void applyLaunchProjectPathFromUrl()
void loadHomeDirectory()
+ void loadRuntimeInfo()
void loadWorkspaceRootOptionsState()
void refreshDefaultProjectName()
})
@@ -587,6 +645,44 @@ watch(accounts, () => {
function onSkillsChanged(): void {
void refreshSkills()
+ void refreshComposerIntegrations()
+}
+
+function slugifyMentionToken(value: string, prefix: '@' | '$'): string {
+ const slug = value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ return `${prefix}${slug || 'item'}`
+}
+
+async function refreshComposerIntegrations(): Promise {
+ const [marketplaces, apps] = await Promise.all([
+ listPlugins(),
+ listApps({ limit: 150 }),
+ ])
+
+ composerPluginOptions.value = marketplaces
+ .flatMap((marketplace) => marketplace.plugins)
+ .filter((plugin) => plugin.installed && plugin.enabled)
+ .map((plugin) => ({
+ name: plugin.displayName,
+ description: plugin.shortDescription || plugin.longDescription || plugin.category,
+ path: `plugin://${plugin.name}@${plugin.marketplaceName}`,
+ token: slugifyMentionToken(plugin.displayName || plugin.name, '@'),
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name))
+
+ composerAppOptions.value = apps
+ .filter((app) => app.isAccessible)
+ .map((app) => ({
+ name: app.name,
+ description: app.description || app.categories.join(' · '),
+ path: `app://${app.id}`,
+ token: slugifyMentionToken(app.name, '$'),
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name))
}
function toggleSidebarSearch(): void {
@@ -712,12 +808,19 @@ function formatResetDateCompact(resetsAt: number | null): string {
return `${date.getMonth() + 1}月${date.getDate()}日`
}
+function resolveDisplayedQuota(account: UiAccountEntry): UiRateLimitSnapshot | null {
+ if (account.isActive && codexQuota.value) {
+ return codexQuota.value
+ }
+ return account.quotaSnapshot
+}
+
function formatAccountQuota(account: UiAccountEntry): string {
if (isAccountUnavailable(account)) {
return account.quotaError || '402 Payment Required'
}
- const quota = account.quotaSnapshot
- const window = pickWeeklyQuotaWindow(account)
+ const quota = resolveDisplayedQuota(account)
+ const window = quota ? pickWeeklyQuotaWindow({ ...account, quotaSnapshot: quota }) : null
if (window) {
const remainingPercent = Math.max(0, Math.min(100, 100 - Math.round(window.usedPercent)))
const refreshDate = formatResetDateCompact(window.resetsAt)
@@ -870,8 +973,9 @@ function onStartNewThread(projectName: string): void {
function onBrowseProjectFiles(projectName: string): void {
const projectGroup = projectGroups.value.find((group) => group.projectName === projectName)
const projectCwd = projectGroup?.threads[0]?.cwd?.trim() ?? ''
- if (!projectCwd || typeof window === 'undefined') return
- window.open(`/codex-local-browse${encodeURI(projectCwd)}`, '_blank', 'noopener,noreferrer')
+ if (!projectCwd) return
+ if (isMobile.value) setSidebarCollapsed(true)
+ void router.push(buildFilesRouteLocation(projectCwd))
}
function onStartNewThreadFromToolbar(): void {
@@ -981,6 +1085,7 @@ async function syncAfterMobileResume(): Promise {
includeSelectedThreadMessages: true,
awaitAncillaryRefreshes: true,
})
+ await refreshComposerIntegrations()
await restoreLastActiveThreadRoute()
await syncThreadSelectionWithRoute()
} finally {
@@ -988,14 +1093,21 @@ async function syncAfterMobileResume(): Promise {
}
}
-function onSubmitThreadMessage(payload: { text: string; imageUrls: string[]; fileAttachments: Array<{ label: string; path: string; fsPath: string }>; skills: Array<{ name: string; path: string }>; mode: 'steer' | 'queue' }): void {
+function onSubmitThreadMessage(payload: {
+ text: string
+ imageUrls: string[]
+ fileAttachments: Array<{ label: string; path: string; fsPath: string }>
+ skills: Array<{ name: string; path: string }>
+ mentions: Array<{ name: string; path: string; token?: string }>
+ mode: 'steer' | 'queue'
+}): void {
const text = payload.text
scheduleMobileConversationJumpToLatest()
if (isHomeRoute.value) {
- void submitFirstMessageForNewThread(text, payload.imageUrls, payload.skills, payload.fileAttachments)
+ void submitFirstMessageForNewThread(text, payload.imageUrls, payload.skills, payload.mentions, payload.fileAttachments)
return
}
- void sendMessageToSelectedThread(text, payload.imageUrls, payload.skills, payload.mode, payload.fileAttachments)
+ void sendMessageToSelectedThread(text, payload.imageUrls, payload.skills, payload.mentions, payload.mode, payload.fileAttachments)
}
function scheduleMobileConversationJumpToLatest(): void {
@@ -1100,6 +1212,15 @@ async function loadHomeDirectory(): Promise {
}
}
+async function loadRuntimeInfo(): Promise {
+ try {
+ const runtimeInfo = await getRuntimeInfo()
+ codexCliVersion.value = runtimeInfo.codexCliVersion || 'unknown'
+ } catch {
+ codexCliVersion.value = 'unknown'
+ }
+}
+
async function loadWorkspaceRootOptionsState(): Promise {
try {
const state = await getWorkspaceRootsState()
@@ -1141,7 +1262,7 @@ function onInterruptTurn(): void {
}
function onExportChat(): void {
- if (isHomeRoute.value || isSkillsRoute.value || typeof document === 'undefined') return
+ if (isHomeRoute.value || isPluginsRoute.value || isAutomationsRoute.value || isSkillsRoute.value || typeof document === 'undefined') return
if (!selectedThread.value || filteredMessages.value.length === 0) return
const markdown = buildThreadMarkdown()
const fileName = buildExportFileName()
@@ -1316,6 +1437,7 @@ async function initialize(): Promise {
await refreshAll({
includeSelectedThreadMessages: true,
})
+ await refreshComposerIntegrations()
void loadAccountsState({ silent: true })
await restoreLastActiveThreadRoute()
hasInitialized.value = true
@@ -1392,7 +1514,7 @@ async function syncThreadSelectionWithRoute(): Promise {
isRouteSyncInProgress.value = true
try {
- if (route.name === 'home' || route.name === 'skills') {
+ if (route.name === 'home' || route.name === 'plugins' || route.name === 'automations' || route.name === 'skills') {
if (selectedThreadId.value !== '') {
await selectThread('')
}
@@ -1416,6 +1538,10 @@ async function syncThreadSelectionWithRoute(): Promise {
return
}
+ if (route.name === 'files') {
+ return
+ }
+
} finally {
isRouteSyncInProgress.value = false
}
@@ -1455,7 +1581,7 @@ watch(
async (threadId) => {
if (!hasInitialized.value) return
if (isRouteSyncInProgress.value) return
- if (isHomeRoute.value || isSkillsRoute.value) return
+ if (isHomeRoute.value || isPluginsRoute.value || isAutomationsRoute.value || isSkillsRoute.value || isFilesRoute.value) return
if (!threadId) {
if (route.name !== 'home') {
@@ -1541,6 +1667,7 @@ async function submitFirstMessageForNewThread(
text: string,
imageUrls: string[] = [],
skills: Array<{ name: string; path: string }> = [],
+ mentions: Array<{ name: string; path: string; token?: string }> = [],
fileAttachments: Array<{ label: string; path: string; fsPath: string }> = [],
): Promise {
try {
@@ -1566,7 +1693,7 @@ async function submitFirstMessageForNewThread(
return
}
}
- const threadId = await sendMessageToNewThread(text, targetCwd, imageUrls, skills, fileAttachments)
+ const threadId = await sendMessageToNewThread(text, targetCwd, imageUrls, skills, mentions, fileAttachments)
if (!threadId) return
await router.replace({ name: 'thread', params: { threadId } })
scheduleMobileConversationJumpToLatest()
diff --git a/src/api/appServerDtos.ts b/src/api/appServerDtos.ts
index c3f921f4..f166a30e 100644
--- a/src/api/appServerDtos.ts
+++ b/src/api/appServerDtos.ts
@@ -4,6 +4,7 @@ export type { ThreadLoadedListResponse } from '../../documentation/app-server-sc
export type { Thread } from '../../documentation/app-server-schemas/typescript/v2/Thread'
export type { ThreadItem } from '../../documentation/app-server-schemas/typescript/v2/ThreadItem'
export type { Turn } from '../../documentation/app-server-schemas/typescript/v2/Turn'
+export type { TurnStartResponse } from '../../documentation/app-server-schemas/typescript/v2/TurnStartResponse'
export type { UserInput } from '../../documentation/app-server-schemas/typescript/v2/UserInput'
export type { ModelListResponse } from '../../documentation/app-server-schemas/typescript/v2/ModelListResponse'
export type { CollaborationModeListResponse } from '../../documentation/app-server-schemas/typescript/v2/CollaborationModeListResponse'
diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts
index 8cbbc2af..5a7f3bbd 100644
--- a/src/api/codexGateway.ts
+++ b/src/api/codexGateway.ts
@@ -14,6 +14,7 @@ import type {
ReasoningEffort,
ThreadListResponse,
ThreadReadResponse,
+ TurnStartResponse,
} from './appServerDtos'
import { normalizeCodexApiError } from './codexErrors'
import {
@@ -21,12 +22,17 @@ import {
normalizeThreadMessagesV2,
readThreadInProgressFromResponse,
} from './normalizers/v2'
+import {
+ normalizeCodexModels,
+ resolveFallbackModelId,
+} from '../utils/codexModels'
import type {
UiAccountEntry,
UiAccountQuotaStatus,
UiAccountUnavailableReason,
CollaborationModeKind,
CollaborationModeOption,
+ UiCodexModel,
UiCreditsSnapshot,
UiFileChange,
UiMessage,
@@ -69,6 +75,10 @@ export type WorkspaceRootsState = {
active: string[]
}
+export type RuntimeInfo = {
+ codexCliVersion: string
+}
+
export type ComposerFileSuggestion = {
path: string
}
@@ -121,6 +131,16 @@ function readBoolean(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null
}
+function readStringArray(value: unknown): string[] {
+ if (!Array.isArray(value)) return []
+ const next: string[] = []
+ for (const item of value) {
+ const normalized = readString(item)
+ if (normalized) next.push(normalized)
+ }
+ return next
+}
+
function normalizeAccountUnavailableReason(value: unknown): UiAccountUnavailableReason | null {
return value === 'payment_required' ? value : null
}
@@ -836,6 +856,7 @@ export async function forkThread(threadId: string): Promise<{ threadId: string;
}
export type FileAttachmentParam = { label: string; path: string; fsPath: string }
+export type MentionParam = { name: string; path: string; token?: string }
function buildTextWithAttachments(
prompt: string,
@@ -849,6 +870,20 @@ function buildTextWithAttachments(
return `${prefix}\n## My request for Codex:\n\n${prompt}\n`
}
+function buildTextWithMentions(
+ prompt: string,
+ mentions: MentionParam[],
+): string {
+ const normalizedPrompt = prompt.trim()
+ if (mentions.length === 0) return normalizedPrompt
+ const tokens = mentions
+ .map((mention) => mention.token?.trim() ?? '')
+ .filter((token) => token.length > 0 && !normalizedPrompt.includes(token))
+
+ if (tokens.length === 0) return normalizedPrompt
+ return `${tokens.join(' ')}${normalizedPrompt ? ` ${normalizedPrompt}` : ''}`
+}
+
async function resolveCollaborationModeSettings(
mode: CollaborationModeKind,
model?: string,
@@ -881,14 +916,14 @@ async function resolveCollaborationModeSettings(
}
}
- let availableModelIds: string[] = []
+ let availableModels: UiCodexModel[] = []
try {
- availableModelIds = await getAvailableModelIds()
+ availableModels = await getAvailableModels()
} catch {
- availableModelIds = []
+ availableModels = []
}
- const fallbackModel = availableModelIds.find((candidate) => candidate.trim().length > 0)?.trim() ?? ''
+ const fallbackModel = resolveFallbackModelId(availableModels)
if (fallbackModel) {
return {
model: fallbackModel,
@@ -908,12 +943,14 @@ export async function startThreadTurn(
model?: string,
effort?: ReasoningEffort,
skills?: Array<{ name: string; path: string }>,
+ mentions?: MentionParam[],
fileAttachments: FileAttachmentParam[] = [],
collaborationMode?: CollaborationModeKind,
-): Promise {
+): Promise {
try {
const normalizedModel = model?.trim() ?? ''
- const finalText = buildTextWithAttachments(text, fileAttachments)
+ const textWithMentions = buildTextWithMentions(text, mentions ?? [])
+ const finalText = buildTextWithAttachments(textWithMentions, fileAttachments)
const input: Array> = [{ type: 'text', text: finalText }]
for (const imageUrl of imageUrls) {
const normalizedUrl = imageUrl.trim()
@@ -929,6 +966,12 @@ export async function startThreadTurn(
input.push({ type: 'skill', name: skill.name, path: skill.path })
}
}
+ if (mentions) {
+ for (const mention of mentions) {
+ if (!mention.name.trim() || !mention.path.trim()) continue
+ input.push({ type: 'mention', name: mention.name, path: mention.path })
+ }
+ }
const attachments = fileAttachments.map((f) => ({ label: f.label, path: f.path, fsPath: f.fsPath }))
const params: Record = {
threadId,
@@ -952,7 +995,8 @@ export async function startThreadTurn(
},
}
}
- await callRpc('turn/start', params)
+ const response = await callRpc('turn/start', params)
+ return readString(response?.turn?.id)?.trim() ?? ''
} catch (error) {
throw normalizeCodexApiError(error, `Failed to start turn for thread ${threadId}`, 'turn/start')
}
@@ -977,15 +1021,14 @@ export async function setDefaultModel(model: string): Promise {
await callRpc('setDefaultModel', { model })
}
-export async function getAvailableModelIds(): Promise {
+export async function getAvailableModels(): Promise {
const payload = await callRpc('model/list', {})
- const ids: string[] = []
- for (const row of payload.data) {
- const candidate = row.id || row.model
- if (!candidate || ids.includes(candidate)) continue
- ids.push(candidate)
- }
- return ids
+ return normalizeCodexModels(payload.data)
+}
+
+export async function getAvailableModelIds(): Promise {
+ const models = await getAvailableModels()
+ return models.map((model) => model.id)
}
export async function getCurrentModelConfig(): Promise {
@@ -1184,6 +1227,26 @@ export async function getHomeDirectory(): Promise {
return typeof data.path === 'string' ? data.path.trim() : ''
}
+export async function getRuntimeInfo(): Promise {
+ const response = await fetch('/codex-api/runtime-info')
+ const payload = (await response.json()) as unknown
+ if (!response.ok) {
+ throw new Error('Failed to load runtime info')
+ }
+ const record =
+ payload && typeof payload === 'object' && !Array.isArray(payload)
+ ? (payload as Record)
+ : {}
+ const data =
+ record.data && typeof record.data === 'object' && !Array.isArray(record.data)
+ ? (record.data as Record)
+ : {}
+
+ return {
+ codexCliVersion: typeof data.codexCliVersion === 'string' ? data.codexCliVersion.trim() || 'unknown' : 'unknown',
+ }
+}
+
export async function setWorkspaceRootsState(nextState: WorkspaceRootsState): Promise {
const response = await fetch('/codex-api/workspace-roots-state', {
method: 'PUT',
@@ -1294,6 +1357,90 @@ export async function searchThreads(
return payload.data ?? { threadIds: [], indexedThreadCount: 0 }
}
+export type FileExplorerEntry = {
+ name: string
+ path: string
+ isDirectory: boolean
+ isFile: boolean
+}
+
+export type FilePathMetadata = {
+ path: string
+ isDirectory: boolean
+ isFile: boolean
+ createdAtMs: number
+ modifiedAtMs: number
+}
+
+export async function getFilePathMetadata(path: string): Promise {
+ const normalizedPath = path.trim()
+ if (!normalizedPath) {
+ throw new Error('Missing file path')
+ }
+
+ const payload = await callRpc<{
+ isDirectory?: boolean
+ isFile?: boolean
+ createdAtMs?: number
+ modifiedAtMs?: number
+ }>('fs/getMetadata', { path: normalizedPath })
+
+ return {
+ path: normalizedPath,
+ isDirectory: payload.isDirectory === true,
+ isFile: payload.isFile === true,
+ createdAtMs: typeof payload.createdAtMs === 'number' ? payload.createdAtMs : 0,
+ modifiedAtMs: typeof payload.modifiedAtMs === 'number' ? payload.modifiedAtMs : 0,
+ }
+}
+
+export async function readDirectoryEntries(path: string): Promise {
+ const normalizedPath = path.trim()
+ if (!normalizedPath) {
+ throw new Error('Missing directory path')
+ }
+
+ const payload = await callRpc<{
+ entries?: Array<{
+ fileName?: string
+ isDirectory?: boolean
+ isFile?: boolean
+ }>
+ }>('fs/readDirectory', { path: normalizedPath })
+
+ const entries = Array.isArray(payload.entries) ? payload.entries : []
+ const normalized = entries
+ .map((entry) => {
+ const name = typeof entry.fileName === 'string' ? entry.fileName.trim() : ''
+ if (!name) return null
+ const basePath = normalizedPath.replace(/\/+$/u, '')
+ return {
+ name,
+ path: basePath ? `${basePath}/${name}` : `/${name}`,
+ isDirectory: entry.isDirectory === true,
+ isFile: entry.isFile === true,
+ } satisfies FileExplorerEntry
+ })
+ .filter((entry): entry is FileExplorerEntry => entry !== null)
+
+ normalized.sort((first, second) => {
+ if (first.isDirectory !== second.isDirectory) return first.isDirectory ? -1 : 1
+ return first.name.localeCompare(second.name)
+ })
+
+ return normalized
+}
+
+export async function readFileContentsBase64(path: string): Promise {
+ const normalizedPath = path.trim()
+ if (!normalizedPath) {
+ throw new Error('Missing file path')
+ }
+
+ const payload = await callRpc<{ dataBase64?: string }>('fs/readFile', { path: normalizedPath })
+ return typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
+}
+
function getErrorMessageFromPayload(payload: unknown, fallback: string): string {
const record = payload && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record)
@@ -1308,6 +1455,288 @@ function getErrorMessageFromPayload(payload: unknown, fallback: string): string
export type ThreadTitleCache = { titles: Record; order: string[] }
+export type UiAutomation = {
+ id: string
+ title: string
+ prompt: string
+ projectPaths: string[]
+ skillNames: string[]
+ enabled: boolean
+ runMode: 'local' | 'worktree'
+ schedulePreset: 'hourly' | 'daily' | 'weekly' | 'custom'
+ cronExpression: string
+ model: string
+ reasoningEffort: string
+ sandboxMode: 'default' | 'read-only' | 'workspace-write' | 'danger-full-access'
+ autoArchiveEmpty: boolean
+ createdAtIso: string
+ updatedAtIso: string
+ nextRunAtIso: string | null
+ lastRunAtIso: string | null
+ lastSuccessAtIso: string | null
+ lastStatus: 'idle' | 'running' | 'succeeded' | 'failed'
+ lastError: string
+}
+
+export type UiAutomationRun = {
+ id: string
+ automationId: string
+ automationTitle: string
+ projectPath: string
+ cwd: string
+ effectiveRunMode: 'local' | 'worktree'
+ worktreeCwd: string
+ status: 'running' | 'completed' | 'failed' | 'archived'
+ unread: boolean
+ archived: boolean
+ startedAtIso: string
+ completedAtIso: string | null
+ summary: string
+ finalMessage: string
+ error: string
+ outputPath: string
+ eventLogPath: string
+ threadId: string
+ model: string
+ reasoningEffort: string
+ sandboxMode: string
+ hasFindings: boolean
+}
+
+export type UiAutomationDefaults = {
+ model: string
+ reasoningEffort: string
+ sandboxMode: string
+}
+
+function normalizeUiAutomation(value: unknown): UiAutomation | null {
+ const record = asRecord(value)
+ if (!record) return null
+ const id = readString(record.id)
+ const title = readString(record.title)
+ const prompt = readString(record.prompt)
+ if (!id || !title || !prompt) return null
+ const sandboxMode = readString(record.sandboxMode)
+ const lastStatus = readString(record.lastStatus)
+ const runMode = readString(record.runMode)
+ const schedulePreset = readString(record.schedulePreset)
+ return {
+ id,
+ title,
+ prompt,
+ projectPaths: readStringArray(record.projectPaths),
+ skillNames: readStringArray(record.skillNames),
+ enabled: readBoolean(record.enabled) ?? true,
+ runMode: runMode === 'worktree' ? 'worktree' : 'local',
+ schedulePreset: schedulePreset === 'hourly' || schedulePreset === 'daily' || schedulePreset === 'weekly' ? schedulePreset : 'custom',
+ cronExpression: readString(record.cronExpression) ?? '',
+ model: readString(record.model) ?? '',
+ reasoningEffort: readString(record.reasoningEffort) ?? '',
+ sandboxMode: sandboxMode === 'read-only' || sandboxMode === 'workspace-write' || sandboxMode === 'danger-full-access' ? sandboxMode : 'default',
+ autoArchiveEmpty: readBoolean(record.autoArchiveEmpty) ?? true,
+ createdAtIso: readString(record.createdAtIso) ?? '',
+ updatedAtIso: readString(record.updatedAtIso) ?? '',
+ nextRunAtIso: readString(record.nextRunAtIso),
+ lastRunAtIso: readString(record.lastRunAtIso),
+ lastSuccessAtIso: readString(record.lastSuccessAtIso),
+ lastStatus: lastStatus === 'running' || lastStatus === 'succeeded' || lastStatus === 'failed' ? lastStatus : 'idle',
+ lastError: readString(record.lastError) ?? '',
+ }
+}
+
+function normalizeUiAutomationRun(value: unknown): UiAutomationRun | null {
+ const record = asRecord(value)
+ if (!record) return null
+ const id = readString(record.id)
+ const automationId = readString(record.automationId)
+ const automationTitle = readString(record.automationTitle)
+ const projectPath = readString(record.projectPath)
+ const cwd = readString(record.cwd)
+ if (!id || !automationId || !automationTitle || !projectPath || !cwd) return null
+ const status = readString(record.status)
+ const effectiveRunMode = readString(record.effectiveRunMode)
+ return {
+ id,
+ automationId,
+ automationTitle,
+ projectPath,
+ cwd,
+ effectiveRunMode: effectiveRunMode === 'worktree' ? 'worktree' : 'local',
+ worktreeCwd: readString(record.worktreeCwd) ?? '',
+ status: status === 'running' || status === 'failed' || status === 'archived' ? status : 'completed',
+ unread: readBoolean(record.unread) ?? false,
+ archived: readBoolean(record.archived) ?? false,
+ startedAtIso: readString(record.startedAtIso) ?? '',
+ completedAtIso: readString(record.completedAtIso),
+ summary: readString(record.summary) ?? '',
+ finalMessage: typeof record.finalMessage === 'string' ? record.finalMessage : '',
+ error: readString(record.error) ?? '',
+ outputPath: readString(record.outputPath) ?? '',
+ eventLogPath: readString(record.eventLogPath) ?? '',
+ threadId: readString(record.threadId) ?? '',
+ model: readString(record.model) ?? '',
+ reasoningEffort: readString(record.reasoningEffort) ?? '',
+ sandboxMode: readString(record.sandboxMode) ?? '',
+ hasFindings: readBoolean(record.hasFindings) ?? false,
+ }
+}
+
+export async function getAutomationsState(): Promise<{
+ automations: UiAutomation[]
+ runs: UiAutomationRun[]
+ defaults: UiAutomationDefaults
+}> {
+ const response = await fetch('/codex-api/automations/state')
+ const payload = (await response.json()) as unknown
+ if (!response.ok) {
+ throw new Error(getErrorMessageFromPayload(payload, 'Failed to load automations'))
+ }
+
+ const envelope = asRecord(payload)
+ const data = asRecord(envelope?.data)
+ const defaultsRecord = asRecord(data?.defaults)
+ return {
+ automations: (Array.isArray(data?.automations) ? data.automations : [])
+ .map((entry) => normalizeUiAutomation(entry))
+ .filter((entry): entry is UiAutomation => entry !== null),
+ runs: (Array.isArray(data?.runs) ? data.runs : [])
+ .map((entry) => normalizeUiAutomationRun(entry))
+ .filter((entry): entry is UiAutomationRun => entry !== null),
+ defaults: {
+ model: readString(defaultsRecord?.model) ?? '',
+ reasoningEffort: readString(defaultsRecord?.reasoningEffort) ?? '',
+ sandboxMode: readString(defaultsRecord?.sandboxMode) ?? '',
+ },
+ }
+}
+
+export async function createAutomation(payload: {
+ title: string
+ prompt: string
+ projectPaths: string[]
+ skillNames: string[]
+ enabled: boolean
+ runMode: 'local' | 'worktree'
+ schedulePreset: 'hourly' | 'daily' | 'weekly' | 'custom'
+ cronExpression: string
+ model: string
+ reasoningEffort: string
+ sandboxMode: 'default' | 'read-only' | 'workspace-write' | 'danger-full-access'
+ autoArchiveEmpty: boolean
+}): Promise {
+ const response = await fetch('/codex-api/automations', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+ const body = (await response.json()) as unknown
+ if (!response.ok) {
+ throw new Error(getErrorMessageFromPayload(body, 'Failed to create automation'))
+ }
+ const record = asRecord(asRecord(body)?.data)
+ const normalized = normalizeUiAutomation(record)
+ if (!normalized) throw new Error('Failed to create automation')
+ return normalized
+}
+
+export async function updateAutomation(
+ id: string,
+ payload: {
+ title: string
+ prompt: string
+ projectPaths: string[]
+ skillNames: string[]
+ enabled: boolean
+ runMode: 'local' | 'worktree'
+ schedulePreset: 'hourly' | 'daily' | 'weekly' | 'custom'
+ cronExpression: string
+ model: string
+ reasoningEffort: string
+ sandboxMode: 'default' | 'read-only' | 'workspace-write' | 'danger-full-access'
+ autoArchiveEmpty: boolean
+ },
+): Promise {
+ const response = await fetch(`/codex-api/automations/${encodeURIComponent(id)}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+ const body = (await response.json()) as unknown
+ if (!response.ok) {
+ throw new Error(getErrorMessageFromPayload(body, 'Failed to update automation'))
+ }
+ const record = asRecord(asRecord(body)?.data)
+ const normalized = normalizeUiAutomation(record)
+ if (!normalized) throw new Error('Failed to update automation')
+ return normalized
+}
+
+export async function deleteAutomation(id: string): Promise {
+ const response = await fetch(`/codex-api/automations/${encodeURIComponent(id)}`, {
+ method: 'DELETE',
+ })
+ const body = (await response.json()) as unknown
+ if (!response.ok) {
+ throw new Error(getErrorMessageFromPayload(body, 'Failed to delete automation'))
+ }
+}
+
+export async function setAutomationEnabled(id: string, enabled: boolean): Promise {
+ const response = await fetch(`/codex-api/automations/${encodeURIComponent(id)}/toggle`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ enabled }),
+ })
+ const body = (await response.json()) as unknown
+ if (!response.ok) {
+ throw new Error(getErrorMessageFromPayload(body, 'Failed to toggle automation'))
+ }
+ const record = asRecord(asRecord(body)?.data)
+ const normalized = normalizeUiAutomation(record)
+ if (!normalized) throw new Error('Failed to toggle automation')
+ return normalized
+}
+
+export async function runAutomationNow(id: string): Promise {
+ const response = await fetch(`/codex-api/automations/${encodeURIComponent(id)}/run`, {
+ method: 'POST',
+ })
+ const body = (await response.json()) as unknown
+ if (!response.ok) {
+ throw new Error(getErrorMessageFromPayload(body, 'Failed to run automation'))
+ }
+}
+
+export async function markAutomationRunRead(id: string): Promise {
+ const response = await fetch(`/codex-api/automations/runs/${encodeURIComponent(id)}/read`, {
+ method: 'POST',
+ })
+ const body = (await response.json()) as unknown
+ if (!response.ok) {
+ throw new Error(getErrorMessageFromPayload(body, 'Failed to update triage item'))
+ }
+ const record = asRecord(asRecord(body)?.data)
+ const normalized = normalizeUiAutomationRun(record)
+ if (!normalized) throw new Error('Failed to update triage item')
+ return normalized
+}
+
+export async function setAutomationRunArchived(id: string, archived: boolean): Promise {
+ const response = await fetch(`/codex-api/automations/runs/${encodeURIComponent(id)}/archive`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ archived }),
+ })
+ const body = (await response.json()) as unknown
+ if (!response.ok) {
+ throw new Error(getErrorMessageFromPayload(body, 'Failed to update triage archive state'))
+ }
+ const record = asRecord(asRecord(body)?.data)
+ const normalized = normalizeUiAutomationRun(record)
+ if (!normalized) throw new Error('Failed to update triage archive state')
+ return normalized
+}
+
export async function getThreadTitleCache(): Promise {
try {
const response = await fetch('/codex-api/thread-titles')
@@ -1348,6 +1777,130 @@ export type SkillInfo = {
enabled: boolean
}
+export type UiPluginSummary = {
+ id: string
+ name: string
+ marketplaceName: string
+ marketplacePath: string
+ installed: boolean
+ enabled: boolean
+ installPolicy: string
+ authPolicy: string
+ displayName: string
+ shortDescription: string
+ longDescription: string
+ developerName: string
+ category: string
+ capabilities: string[]
+ websiteUrl: string
+ privacyPolicyUrl: string
+ termsOfServiceUrl: string
+ brandColor: string
+ defaultPrompt: string[]
+}
+
+export type UiPluginMarketplace = {
+ name: string
+ displayName: string
+ path: string
+ plugins: UiPluginSummary[]
+}
+
+export type UiPluginDetailSkill = {
+ name: string
+ displayName: string
+ description: string
+ shortDescription: string
+ path: string
+ enabled: boolean
+}
+
+export type UiPluginApp = {
+ id: string
+ name: string
+ description: string
+ installUrl: string
+ needsAuth: boolean
+}
+
+export type UiPluginDetail = {
+ marketplaceName: string
+ marketplacePath: string
+ summary: UiPluginSummary
+ description: string
+ skills: UiPluginDetailSkill[]
+ apps: UiPluginApp[]
+ mcpServers: string[]
+}
+
+export type UiPluginInstallResult = {
+ authPolicy: string
+ appsNeedingAuth: UiPluginApp[]
+}
+
+export type UiAppInfo = {
+ id: string
+ name: string
+ description: string
+ installUrl: string
+ logoUrl: string
+ logoUrlDark: string
+ distributionChannel: string
+ isAccessible: boolean
+ isEnabled: boolean
+ categories: string[]
+ labels: Record
+ pluginDisplayNames: string[]
+}
+
+function normalizeAppList(value: unknown): UiAppInfo[] {
+ const record = asRecord(value)
+ const rows = Array.isArray(record?.data) ? record.data : []
+ return rows.flatMap((row) => {
+ const app = asRecord(row)
+ if (!app) return []
+ const id = readString(app.id)
+ const name = readString(app.name)
+ if (!id || !name) return []
+ const metadata = asRecord(app.appMetadata)
+ const labelsRecord = asRecord(app.labels)
+ const labels: Record = {}
+ if (labelsRecord) {
+ for (const [key, value] of Object.entries(labelsRecord)) {
+ const normalized = readString(value)
+ if (normalized) labels[key] = normalized
+ }
+ }
+ return [{
+ id,
+ name,
+ description: readString(app.description) ?? '',
+ installUrl: readString(app.installUrl) ?? '',
+ logoUrl: readString(app.logoUrl) ?? '',
+ logoUrlDark: readString(app.logoUrlDark) ?? '',
+ distributionChannel: readString(app.distributionChannel) ?? '',
+ isAccessible: readBoolean(app.isAccessible) ?? false,
+ isEnabled: readBoolean(app.isEnabled) ?? false,
+ categories: readStringArray(metadata?.categories),
+ labels,
+ pluginDisplayNames: readStringArray(app.pluginDisplayNames),
+ }]
+ })
+}
+
+export type UiMcpToolInfo = {
+ name: string
+ description: string
+}
+
+export type UiMcpServerStatus = {
+ name: string
+ authStatus: string
+ tools: UiMcpToolInfo[]
+ resourceCount: number
+ resourceTemplateCount: number
+}
+
type SkillsListResponseEntry = {
cwd: string
skills: Array<{
@@ -1387,6 +1940,231 @@ export async function getSkillsList(cwds?: string[]): Promise {
}
}
+function normalizePluginSummary(
+ marketplaceName: string,
+ marketplacePath: string,
+ value: unknown,
+): UiPluginSummary | null {
+ const record = asRecord(value)
+ if (!record) return null
+
+ const id = readString(record.id)
+ const name = readString(record.name)
+ if (!id || !name) return null
+
+ const pluginInterface = asRecord(record.interface)
+
+ return {
+ id,
+ name,
+ marketplaceName,
+ marketplacePath,
+ installed: readBoolean(record.installed) ?? false,
+ enabled: readBoolean(record.enabled) ?? false,
+ installPolicy: readString(record.installPolicy) ?? '',
+ authPolicy: readString(record.authPolicy) ?? '',
+ displayName: readString(pluginInterface?.displayName) ?? name,
+ shortDescription: readString(pluginInterface?.shortDescription) ?? '',
+ longDescription: readString(pluginInterface?.longDescription) ?? '',
+ developerName: readString(pluginInterface?.developerName) ?? '',
+ category: readString(pluginInterface?.category) ?? '',
+ capabilities: readStringArray(pluginInterface?.capabilities),
+ websiteUrl: readString(pluginInterface?.websiteUrl) ?? '',
+ privacyPolicyUrl: readString(pluginInterface?.privacyPolicyUrl) ?? '',
+ termsOfServiceUrl: readString(pluginInterface?.termsOfServiceUrl) ?? '',
+ brandColor: readString(pluginInterface?.brandColor) ?? '',
+ defaultPrompt: readStringArray(pluginInterface?.defaultPrompt),
+ }
+}
+
+export async function listPlugins(cwds?: string[], forceRemoteSync = false): Promise {
+ try {
+ const params: Record = {}
+ if (cwds && cwds.length > 0) params.cwds = cwds
+ if (forceRemoteSync) params.forceRemoteSync = true
+ const payload = await callRpc('plugin/list', params)
+ const record = asRecord(payload)
+ const rows = Array.isArray(record?.marketplaces) ? record.marketplaces : []
+ const marketplaces: UiPluginMarketplace[] = []
+
+ for (const row of rows) {
+ const marketplace = asRecord(row)
+ if (!marketplace) continue
+ const name = readString(marketplace.name)
+ const path = readString(marketplace.path)
+ if (!name || !path) continue
+ const marketplaceInterface = asRecord(marketplace.interface)
+ const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : []
+ marketplaces.push({
+ name,
+ displayName: readString(marketplaceInterface?.displayName) ?? name,
+ path,
+ plugins: plugins
+ .map((plugin) => normalizePluginSummary(name, path, plugin))
+ .filter((plugin): plugin is UiPluginSummary => plugin !== null),
+ })
+ }
+
+ return marketplaces
+ } catch {
+ return []
+ }
+}
+
+function normalizePluginApp(value: unknown): UiPluginApp | null {
+ const record = asRecord(value)
+ if (!record) return null
+ const id = readString(record.id)
+ const name = readString(record.name)
+ if (!id || !name) return null
+ return {
+ id,
+ name,
+ description: readString(record.description) ?? '',
+ installUrl: readString(record.installUrl) ?? '',
+ needsAuth: readBoolean(record.needsAuth) ?? false,
+ }
+}
+
+export async function readPluginDetail(marketplacePath: string, pluginName: string): Promise {
+ try {
+ const payload = await callRpc('plugin/read', { marketplacePath, pluginName })
+ const record = asRecord(payload)
+ const plugin = asRecord(record?.plugin)
+ if (!plugin) return null
+
+ const normalizedMarketplacePath = readString(plugin.marketplacePath)
+ const marketplaceName = readString(plugin.marketplaceName)
+ const summary = normalizePluginSummary(
+ marketplaceName ?? '',
+ normalizedMarketplacePath ?? '',
+ plugin.summary,
+ )
+ if (!summary || !marketplaceName || !normalizedMarketplacePath) return null
+
+ const skills = Array.isArray(plugin.skills) ? plugin.skills : []
+ const apps = Array.isArray(plugin.apps) ? plugin.apps : []
+
+ return {
+ marketplaceName,
+ marketplacePath: normalizedMarketplacePath,
+ summary,
+ description: readString(plugin.description) ?? '',
+ skills: skills.flatMap((skill) => {
+ const record = asRecord(skill)
+ if (!record) return []
+ const name = readString(record.name)
+ if (!name) return []
+ const skillInterface = asRecord(record.interface)
+ return [{
+ name,
+ displayName: readString(skillInterface?.displayName) ?? name,
+ description: readString(record.description) ?? '',
+ shortDescription: readString(skillInterface?.shortDescription) ?? '',
+ path: readString(record.path) ?? '',
+ enabled: readBoolean(record.enabled) ?? false,
+ }]
+ }),
+ apps: apps
+ .map((app) => normalizePluginApp(app))
+ .filter((app): app is UiPluginApp => app !== null),
+ mcpServers: readStringArray(plugin.mcpServers),
+ }
+ } catch {
+ return null
+ }
+}
+
+export async function installPlugin(marketplacePath: string, pluginName: string): Promise {
+ try {
+ const payload = await callRpc('plugin/install', { marketplacePath, pluginName })
+ const record = asRecord(payload)
+ return {
+ authPolicy: readString(record?.authPolicy) ?? '',
+ appsNeedingAuth: (Array.isArray(record?.appsNeedingAuth) ? record?.appsNeedingAuth : [])
+ .map((app) => normalizePluginApp(app))
+ .filter((app): app is UiPluginApp => app !== null),
+ }
+ } catch {
+ return null
+ }
+}
+
+export async function uninstallPlugin(pluginId: string): Promise {
+ try {
+ await callRpc('plugin/uninstall', { pluginId })
+ return true
+ } catch {
+ return false
+ }
+}
+
+export async function listApps(params: { threadId?: string; forceRefetch?: boolean; limit?: number } = {}): Promise {
+ try {
+ const payload = await callRpc('app/list', params)
+ return normalizeAppList(payload)
+ } catch {
+ return []
+ }
+}
+
+export function normalizeAppListNotification(payload: unknown): UiAppInfo[] {
+ return normalizeAppList(payload)
+}
+
+export async function listMcpServers(limit = 100): Promise {
+ try {
+ const payload = await callRpc('mcpServerStatus/list', { limit, detail: 'full' })
+ const record = asRecord(payload)
+ const rows = Array.isArray(record?.data) ? record.data : []
+ return rows.flatMap((row) => {
+ const server = asRecord(row)
+ if (!server) return []
+ const name = readString(server.name)
+ if (!name) return []
+ const toolsRecord = asRecord(server.tools)
+ const tools: UiMcpToolInfo[] = []
+ if (toolsRecord) {
+ for (const [toolName, value] of Object.entries(toolsRecord)) {
+ const tool = asRecord(value)
+ tools.push({
+ name: toolName,
+ description: readString(tool?.description) ?? '',
+ })
+ }
+ }
+ return [{
+ name,
+ authStatus: readString(server.authStatus) ?? 'unsupported',
+ tools,
+ resourceCount: Array.isArray(server.resources) ? server.resources.length : 0,
+ resourceTemplateCount: Array.isArray(server.resourceTemplates) ? server.resourceTemplates.length : 0,
+ }]
+ })
+ } catch {
+ return []
+ }
+}
+
+export async function startMcpOauthLogin(serverName: string): Promise {
+ try {
+ const payload = await callRpc('mcpServer/oauth/login', { name: serverName })
+ const record = asRecord(payload)
+ return readString(record?.authorizationUrl) ?? null
+ } catch {
+ return null
+ }
+}
+
+export async function reloadMcpServers(): Promise {
+ try {
+ await callRpc('config/mcpServer/reload', {})
+ return true
+ } catch {
+ return false
+ }
+}
+
export async function uploadFile(file: File): Promise {
try {
const form = new FormData()
diff --git a/src/components/content/AutomationsHub.vue b/src/components/content/AutomationsHub.vue
new file mode 100644
index 00000000..918a468c
--- /dev/null
+++ b/src/components/content/AutomationsHub.vue
@@ -0,0 +1,997 @@
+
+
+
+
+
+ {{ toast.text }}
+
+
+
+ Local scheduler
+ `codex exec` runner
+ {{ defaults.model || 'default model' }}
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+ No triage items for the current filter.
+
+
+
+
+
+ {{ run.automationTitle }}
+ {{ runStatusLabel(run) }}
+
+ {{ shortPath(run.projectPath) }} · {{ formatDateTime(run.startedAtIso) }}
+ {{ run.summary || (run.error || 'Run finished.') }}
+
+
+
+
+
+
+
+
+
+
+ Started
+ {{ formatDateTime(selectedRun.startedAtIso) }}
+
+
+ Completed
+ {{ selectedRun.completedAtIso ? formatDateTime(selectedRun.completedAtIso) : 'Running…' }}
+
+
+ Mode
+ {{ selectedRun.effectiveRunMode }}
+
+
+ Model
+ {{ selectedRun.model || defaults.model || 'default' }}
+
+
+
+ {{ selectedRun.error }}
+ Auto-archived because the run did not report notable findings.
+
+ {{ selectedRun.finalMessage || 'No final message recorded yet.' }}
+
+ Select a triage item to inspect the latest run output.
+
+
+
+
+
+
+
+
+ No automations yet. Create one to schedule recurring work.
+
+
+
+
+
+ {{ automation.title }}
+
+ {{ automation.enabled ? 'Enabled' : 'Paused' }}
+
+
+
+ {{ automation.projectPaths.length }} project{{ automation.projectPaths.length === 1 ? '' : 's' }} · {{ automation.runMode }}
+
+ {{ automation.prompt }}
+
+ cron: {{ automation.cronExpression }}
+ next: {{ formatRelative(automation.nextRunAtIso) }}
+ {{ automation.skillNames.length }} skills
+
+
+ Edit
+
+ {{ runningAutomationId === automation.id ? 'Running…' : 'Run now' }}
+
+
+ {{ automation.enabled ? 'Pause' : 'Enable' }}
+
+
+ {{ deletingAutomationId === automation.id ? 'Deleting…' : 'Delete' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/content/ComposerSkillPicker.vue b/src/components/content/ComposerSkillPicker.vue
index 22623da9..77adce24 100644
--- a/src/components/content/ComposerSkillPicker.vue
+++ b/src/components/content/ComposerSkillPicker.vue
@@ -22,7 +22,10 @@
@click="$emit('select', skill)"
@pointerenter="highlightIndex = idx"
>
- {{ skill.name }}
+
+ {{ skill.name }}
+ {{ projectBadgeLabel(skill) }}
+
{{ skill.description }}
@@ -38,6 +41,8 @@ export type SkillOption = {
name: string
description: string
path: string
+ scope?: string
+ projectName?: string
}
const props = defineProps<{
@@ -61,7 +66,9 @@ const filtered = computed(() => {
const q = query.value.toLowerCase().trim()
if (!q) return props.skills
return props.skills.filter(
- (s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
+ (s) => s.name.toLowerCase().includes(q)
+ || s.description.toLowerCase().includes(q)
+ || (s.projectName ?? '').toLowerCase().includes(q),
)
})
@@ -94,6 +101,10 @@ watch(() => props.visible, (v) => {
watch(query, () => {
highlightIndex.value = 0
})
+
+function projectBadgeLabel(skill: SkillOption): string {
+ return skill.scope === 'repo' && skill.projectName ? `Project · ${skill.projectName}` : ''
+}
diff --git a/src/components/content/PluginDetailModal.vue b/src/components/content/PluginDetailModal.vue
new file mode 100644
index 00000000..13604def
--- /dev/null
+++ b/src/components/content/PluginDetailModal.vue
@@ -0,0 +1,324 @@
+
+
+
+
+
+
+
+
{{ summaryText }}
+
+
+ {{ capability }}
+
+
+
Loading plugin details…
+
+
+
+ Bundled Skills ({{ detail.skills.length }})
+
+
+
+
+
+
{{ skill.displayName }}
+
+ {{ skill.shortDescription || skill.description }}
+
+
+
+ {{ skill.enabled ? 'Enabled' : 'Disabled' }}
+
+
+
+
+
+
+ Apps
+
+
+
+
{{ app.name }}
+
{{ app.description }}
+
+
+ {{ app.needsAuth ? 'Connect' : 'Open' }}
+
+
+
+
+
+
+ MCP Servers
+
+ {{ server }}
+
+
+
+
+
+ Finish Setup
+ This plugin still needs connector authorization.
+
+
+
+
{{ app.name }}
+
{{ app.description }}
+
+
+ Connect
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/content/PluginsHub.vue b/src/components/content/PluginsHub.vue
new file mode 100644
index 00000000..1f027bc6
--- /dev/null
+++ b/src/components/content/PluginsHub.vue
@@ -0,0 +1,745 @@
+
+
+
+
+
+ {{ toast.text }}
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
The connected Codex runtime does not expose plugin RPC methods.
+
+
+ Installed ({{ filteredInstalledPlugins.length }})
+
+
+
+
{{ plugin.displayName.slice(0, 1).toUpperCase() }}
+
+
+ {{ plugin.displayName }}
+ Installed
+
+
{{ plugin.marketplaceName }} · {{ plugin.category }}
+
+
+
+ {{ plugin.shortDescription || plugin.longDescription }}
+
+
+
+
+
+
+ Browse Plugins ({{ filteredAvailablePlugins.length }})
+ Loading plugins…
+ No plugins match the current search.
+
+
+
+
{{ plugin.displayName.slice(0, 1).toUpperCase() }}
+
+
+ {{ plugin.displayName }}
+ {{ plugin.installPolicy === 'NOT_AVAILABLE' ? 'Unavailable' : 'Available' }}
+
+
{{ plugin.marketplaceName }} · {{ plugin.category }}
+
+
+
+ {{ plugin.shortDescription || plugin.longDescription }}
+
+
+
+
+
+
+
+
+
The connected Codex runtime does not expose app/list.
+
+
+ Connected Apps ({{ filteredConnectedApps.length }})
+
+
+
+
{{ app.name.slice(0, 1).toUpperCase() }}
+
+
+ {{ app.name }}
+ Connected
+
+
{{ app.categories.join(' · ') || 'App' }}
+
+
+ {{ app.description }}
+
+ {{ name }}
+
+ Open
+
+
+
+
+
+ Available Apps ({{ filteredAvailableApps.length }})
+ Loading apps…
+ No apps match the current search.
+
+
+
+
{{ app.name.slice(0, 1).toUpperCase() }}
+
+
+ {{ app.name }}
+ Install in ChatGPT
+
+
{{ app.categories.join(' · ') || 'App' }}
+
+
+ {{ app.description }}
+
+ {{ name }}
+
+ Connect
+
+
+
+
+
+
+
+
The connected Codex runtime does not expose MCP status methods.
+
+ Configured MCP Servers ({{ filteredMcpServers.length }})
+ Loading MCP servers…
+ No MCP servers match the current search.
+
+
+
+
+ {{ server.name }}
+ {{ formatAuthStatus(server.authStatus) }}
+
+
+ {{ server.tools.length }} tools · {{ server.resourceCount }} resources · {{ server.resourceTemplateCount }} templates
+
+
+ {{ tool.name }}
+
+
+
+ Connect OAuth
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/content/ReviewPane.vue b/src/components/content/ReviewPane.vue
index fe0ebcfe..d20dcae1 100644
--- a/src/components/content/ReviewPane.vue
+++ b/src/components/content/ReviewPane.vue
@@ -1210,7 +1210,7 @@ onBeforeUnmount(() => {
.review-pane-content,
.review-pane-findings {
- @apply min-h-0 flex-1 overflow-hidden;
+ @apply min-h-0 flex-1 flex flex-col overflow-hidden;
}
.review-pane-bulk-actions {
@@ -1334,7 +1334,8 @@ onBeforeUnmount(() => {
}
.review-pane-diff {
- @apply min-h-0 overflow-y-auto px-3 py-3;
+ @apply min-h-0 flex-1 overflow-y-auto px-3 py-3;
+ -webkit-overflow-scrolling: touch;
}
.review-pane-file-header,
@@ -1444,7 +1445,8 @@ onBeforeUnmount(() => {
}
.review-pane-findings-list {
- @apply flex h-full flex-col gap-2.5 overflow-y-auto px-3 py-3;
+ @apply flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-3 py-3;
+ -webkit-overflow-scrolling: touch;
}
.review-pane-finding {
@@ -1613,7 +1615,7 @@ onBeforeUnmount(() => {
}
.review-pane-main {
- @apply block;
+ @apply flex h-full min-h-0 flex-col;
}
.review-pane-resizer {
diff --git a/src/components/content/SkillCard.vue b/src/components/content/SkillCard.vue
index 13b68402..10cd0514 100644
--- a/src/components/content/SkillCard.vue
+++ b/src/components/content/SkillCard.vue
@@ -21,7 +21,10 @@
Disabled
Installed
-
{{ skill.owner }}
+
+ {{ skill.owner }}
+ {{ projectBadgeLabel }}
+