Skip to content
Open
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@
- Reproduce the issue with a focused test when feasible; if direct reproduction is impractical, document the exact reasoning and code evidence used to accept or reject the finding.
- Prefer adding or updating a regression test for every accepted review-bot bug before or alongside the fix.
- Do not patch purely to satisfy a bot comment if the behavior is correct, stale, already fixed, or the proposed change would make the implementation worse.
- After pushing any commit to an open PR, wait and poll for Qodo/review-bot comments and PR review status for about 30 seconds before reporting the push workflow as complete.
- After fixing an accepted review-bot finding, run the narrow regression test plus the relevant build/typecheck command, push the commit, and re-check the PR comments/status.
- In the completion report, distinguish confirmed fixes from stale or rejected bot comments.

Expand Down Expand Up @@ -142,6 +141,7 @@
- viewport(s)
- assertion/result summary
- screenshot absolute path(s)
- inline screenshot image(s) rendered in chat with Markdown image syntax using absolute local paths
- CJS command/result (when module-loading behavior was changed)

## Worktree Dev Server Rule
Expand Down Expand Up @@ -181,6 +181,7 @@

- When the user asks to test with Playwright, run the verification on the explicitly requested project/thread context (for example `TestChat`).
- Screenshot artifacts must show complete passing evidence for the tested feature, not only the base page load.
- Always show captured screenshots inline in the chat, not only as links or filesystem paths. Use Markdown image tags with absolute local paths, for example `![light verification](/absolute/path/output/playwright/example.png)`.
- For UI work, include dark-theme evidence in addition to the default/light-theme evidence unless the task is explicitly light-only.
- For refresh-persistence fixes, include a post-refresh screenshot that still shows the expected UI state.

Expand Down Expand Up @@ -209,6 +210,7 @@
- exact CJS command/script path
- assertion summary (`hrefOk`, `titleOk`, `textOk`)
- screenshot absolute path
- inline screenshot image rendered in chat with Markdown image syntax using the absolute screenshot path

## LLM Wiki Schema

Expand Down
14 changes: 14 additions & 0 deletions llm-wiki/raw/features/project-cron-automations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Project cron automations source

Date: 2026-05-10

Project-scoped automations are represented as Codex cron automations with a `cwds` array containing absolute project folder paths. The sidebar resolves display labels such as `TestChat` to the real cwd before saving so the Codex scheduler can run in the intended project. The project automation UI blocks unresolved/non-absolute project cwd values. Editing or deleting a project association on a multi-cwd cron automation preserves the other cwd associations and only deletes the automation folder when the final cwd is removed. Thread automations remain heartbeat automations keyed by `target_thread_id`.

Implementation facts:
- `src/server/codexAppServerBridge.ts` parses and serializes `cwds` in automation TOML records.
- `GET /codex-api/project-automations` returns project cron automations grouped by project cwd.
- `GET`, `PUT`, and `DELETE /codex-api/project-automation` read, save, and remove automations for one project cwd.
- `src/api/codexGateway.ts` exposes project automation helpers mirroring the thread automation helpers.
- `src/components/sidebar/SidebarThreadTree.vue` adds project menu `Add automation…` / `Manage automations…`, project row automation icons, and reuses the existing automation dialog with project-specific copy.
- `src/components/content/AutomationsPanel.vue` lists both thread heartbeat automations and project cron automations in one top-level panel. It sorts active automations before paused automations, newest first within each status group, and exposes row/detail edit buttons that open the shared automation editor.
- Project automations intentionally do not expose `Run now`; the existing manual run behavior remains thread-heartbeat-only.
23 changes: 23 additions & 0 deletions llm-wiki/wiki/concepts/project-cron-automations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Project Cron Automations

Project automations extend the existing sidebar automation UI from thread-scoped heartbeat records to project-scoped cron records.

## Storage

Project automations use Codex cron automation TOML records with `cwds = ["<absolute project path>"]`. Sidebar display labels must be resolved to the real project folder before saving, so folder-name rows such as `TestChat` still write the scheduler-visible cwd. The UI/server reject unresolved non-absolute cwd values for project automation saves. Multi-cwd cron records keep their other cwd associations when edited or removed from one project, and the automation folder is deleted only when the final cwd association is removed. This matches Codex's project/folder automation shape while preserving thread heartbeat records that use `target_thread_id`.

Source: [project-cron-automations.md](../../raw/features/project-cron-automations.md)

## UI

The sidebar project row dots menu exposes `Add automation…` or `Manage automations…`. The dialog reuses the thread automation manager style, including multiple automation selection, schedule presets, status, and remove/save behavior.

Project rows show the same compact automation icon when at least one project automation is attached. The top-level Automations panel lists both project cron automations and thread heartbeat automations together, sorts active automations before paused automations with newest records first inside each status group, and exposes edit buttons that open the shared automation editor.

Source: [project-cron-automations.md](../../raw/features/project-cron-automations.md)

## Boundaries

`Run now` remains available only for thread heartbeat automations because it queues a heartbeat message into a concrete thread. Project cron automations are scheduled against one or more working directories instead.

Source: [project-cron-automations.md](../../raw/features/project-cron-automations.md)
2 changes: 2 additions & 0 deletions llm-wiki/wiki/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@
- [concepts/realtime-chat-rendering.md](./concepts/realtime-chat-rendering.md): realtime chat rendering, sync-churn reduction, and inline media sanitization.
- [concepts/skills-route-ui.md](./concepts/skills-route-ui.md): Skills route naming, first-launch Plugins card persistence, dark-theme fixes, and verification lessons.
- [concepts/thread-heartbeat-automations.md](./concepts/thread-heartbeat-automations.md): thread-scoped heartbeat automation storage, multi-automation management, and manual run behavior.
- [concepts/project-cron-automations.md](./concepts/project-cron-automations.md): project-scoped cron automation storage and sidebar management UI.

## Sources
- [../raw/features/integrated-terminal.md](../raw/features/integrated-terminal.md): source facts for the integrated terminal implementation and follow-up tests.
- [../raw/features/directory-hub-composio-skills-search.md](../raw/features/directory-hub-composio-skills-search.md): source facts for Directory Hub, Composio connectors, Skills search/install, and edge-case tests.
- [../raw/features/realtime-chat-rendering-inline-media.md](../raw/features/realtime-chat-rendering-inline-media.md): source facts for realtime chat rendering and inline media sanitization.
- [../raw/features/skills-route-ui-and-first-launch-card.md](../raw/features/skills-route-ui-and-first-launch-card.md): source facts for the Skills route rename, first-launch Plugins card, dark-theme fix, and dev-server workflow adjustment.
- [../raw/features/thread-heartbeat-automations.md](../raw/features/thread-heartbeat-automations.md): source facts for thread heartbeat automations, multiple automations per thread, and Run now queue behavior.
- [../raw/features/project-cron-automations.md](../raw/features/project-cron-automations.md): source facts for project cron automations in the sidebar.
- [../raw/projects/codex-web-local.md](../raw/projects/codex-web-local.md): immutable source snapshot for project facts.
- [../raw/fixes/opencode-zen-big-pickle-codex-cli.md](../raw/fixes/opencode-zen-big-pickle-codex-cli.md): Big Pickle + Codex CLI fix details.
- [../raw/fixes/opencode-zen-reasoning-content-proxy.md](../raw/fixes/opencode-zen-reasoning-content-proxy.md): Codex Web Local Zen proxy reasoning_content round-trip fix and Docker verification.
6 changes: 6 additions & 0 deletions llm-wiki/wiki/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,9 @@
- Updated wiki page: `concepts/opencode-zen-big-pickle.md`.
- Documents: DeepSeek thinking-mode `reasoning_content` round-trip requirement, Chat-shaped Zen proxy endpoint selection, streaming reasoning preservation, Docker validation, and the `/tmp/app.tar` restart gotcha.
- Updated `index.md`.
## 2026-05-10

- Added project cron automation notes for sidebar project-level automation management.
- Updated project cron automation notes for the combined Automations panel.
- Updated Automations panel notes for active/newest sorting and direct edit buttons.
- Updated project cron automation notes for absolute cwd validation and multi-cwd preservation.
110 changes: 103 additions & 7 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,25 @@
</span>
</button>

<SidebarThreadTree :groups="projectGroups" :project-display-name-by-id="projectDisplayNameById"
<button
v-if="!isSidebarCollapsed"
class="sidebar-skills-link"
:class="{ 'is-active': isAutomationsRoute }"
type="button"
@click="router.push({ name: 'automations' }); isMobile && setSidebarCollapsed(true)"
>
<span class="sidebar-skills-link-icon sidebar-automations-link-icon" aria-hidden="true">
<IconTablerBolt />
</span>
<span class="sidebar-skills-link-copy">
<span class="sidebar-skills-link-title">{{ t('Automations') }}</span>
<span class="sidebar-skills-link-subtitle">{{ t('Scheduled work') }}</span>
</span>
</button>

<SidebarThreadTree ref="sidebarThreadTreeRef" :groups="projectGroups" :project-display-name-by-id="projectDisplayNameById"
:project-git-repo-by-name="projectGitRepoByName"
:project-cwd-by-name="projectCwdByName"
v-if="!isSidebarCollapsed"
:selected-thread-id="selectedThreadId" :is-loading="isLoadingThreads"
:is-thread-list-fully-loaded="isThreadListFullyLoaded"
Expand All @@ -77,6 +94,7 @@
@fork-thread="onForkThread"
@remove-project="onRemoveProject" @reorder-project="onReorderProject"
@export-thread="onExportThread"
@automations-changed="onAutomationsChanged"
@start-new-chat="onStartNewThreadFromToolbar" />
</div>

Expand Down Expand Up @@ -484,7 +502,7 @@
:style="contentStyle"
>
<span v-if="isVirtualKeyboardOpen" class="content-keyboard-spacer" aria-hidden="true" />
<ContentHeader :title="contentTitle" :accent="isSkillsRoute">
<ContentHeader :title="contentTitle" :accent="isSkillsRoute || isAutomationsRoute">
<template #leading>
<SidebarThreadControls
v-if="isSidebarCollapsed || isMobile"
Expand All @@ -497,6 +515,9 @@
<span v-if="isSkillsRoute" class="skills-route-header-icon" aria-hidden="true">
<IconTablerBolt />
</span>
<span v-else-if="isAutomationsRoute" class="skills-route-header-icon automations-route-header-icon" aria-hidden="true">
<IconTablerBolt />
</span>
</template>
<template #actions>
<ComposerDropdown
Expand Down Expand Up @@ -547,6 +568,17 @@
@try-item="onTryDirectoryItem"
/>
</template>
<template v-else-if="isAutomationsRoute">
<AutomationsPanel
ref="automationsPanelRef"
:groups="projectGroups"
:project-cwd-by-name="projectCwdByName"
:selected-automation-id="routeAutomationId"
@select-automation="onSelectAutomationInPanel"
@edit-automation="onEditAutomationFromPanel"
@create-automation="onCreateAutomationFromPanel"
/>
</template>
<template v-else-if="isHomeRoute">
<div class="content-grid content-grid-home">
<div class="new-thread-empty">
Expand Down Expand Up @@ -999,7 +1031,7 @@ import {
searchThreads,
switchAccount,
} from './api/codexGateway'
import type { ReasoningEffort, SpeedMode, UiAccountEntry, UiRateLimitWindow, UiServerRequest, UiServerRequestReply, UiThreadTokenUsage } from './types/codex'
import type { ReasoningEffort, SpeedMode, UiAccountEntry, UiRateLimitWindow, UiServerRequest, UiServerRequestReply, UiThreadAutomation, UiThreadTokenUsage } from './types/codex'
import type { ComposerDraftPayload, ThreadComposerExposed } from './components/content/ThreadComposer.vue'
import type { GitCommitOption, LocalDirectoryEntry, TelegramStatus, ThreadTerminalQuickCommand, WorktreeBranchOption } from './api/codexGateway'
import { getFreeModeStatus, setFreeMode, setFreeModeCustomKey, setCustomProvider } from './api/codexGateway'
Expand All @@ -1009,6 +1041,7 @@ const ThreadConversation = defineAsyncComponent(() => import('./components/conte
const ThreadTerminalPanel = defineAsyncComponent(() => import('./components/content/ThreadTerminalPanel.vue'))
const ReviewPane = defineAsyncComponent(() => import('./components/content/ReviewPane.vue'))
const DirectoryHub = defineAsyncComponent(() => import('./components/content/DirectoryHub.vue'))
const AutomationsPanel = defineAsyncComponent(() => import('./components/content/AutomationsPanel.vue'))
const { t, uiLanguage, uiLanguageOptions, setUiLanguage } = useUiLanguage()

const SIDEBAR_COLLAPSED_STORAGE_KEY = 'codex-web-local.sidebar-collapsed.v1'
Expand Down Expand Up @@ -1241,6 +1274,20 @@ const {
const route = useRoute()
const router = useRouter()
const { isMobile } = useMobile()
type SidebarThreadTreeExposed = {
openAutomationEditorFromPanel: (payload: AutomationEditRequest) => void
openAutomationCreatorFromPanel: () => void
}
type AutomationsPanelExposed = {
loadAutomations: () => Promise<void>
}
type AutomationEditRequest = {
scope: 'thread' | 'project'
target: string
automation: UiThreadAutomation
}
const sidebarThreadTreeRef = ref<SidebarThreadTreeExposed | null>(null)
const automationsPanelRef = ref<AutomationsPanelExposed | null>(null)
const homeThreadComposerRef = ref<ThreadComposerExposed | null>(null)
const threadComposerRef = ref<ThreadComposerExposed | null>(null)
const threadConversationRef = ref<{ jumpToLatest: () => void } | null>(null)
Expand Down Expand Up @@ -1397,7 +1444,13 @@ const routeThreadId = computed(() => {

const isHomeRoute = computed(() => route.name === 'home')
const isSkillsRoute = computed(() => route.name === 'skills')
const isAutomationsRoute = computed(() => route.name === 'automations')
const routeAutomationId = computed(() => {
const raw = route.query.automationId
return typeof raw === 'string' ? raw : ''
})
const contentTitle = computed(() => {
if (isAutomationsRoute.value) return t('Automations')
if (isSkillsRoute.value) return t('Skills')
if (isHomeRoute.value) return t('Start new thread')
return selectedThread.value?.title ?? t('Choose a thread')
Expand Down Expand Up @@ -1462,7 +1515,7 @@ const isTerminalKeyboardLayoutActive = computed(() => (
))
const directoryCwd = computed(() => selectedThread.value?.cwd?.trim() ?? newThreadCwd.value.trim())
const isSelectedThreadInProgress = computed(() => !isHomeRoute.value && selectedThread.value?.inProgress === true)
const showThreadContextBadge = computed(() => !isHomeRoute.value && !isSkillsRoute.value && selectedThreadId.value.trim().length > 0)
const showThreadContextBadge = computed(() => !isHomeRoute.value && !isSkillsRoute.value && !isAutomationsRoute.value && selectedThreadId.value.trim().length > 0)
const isAccountSwitchBlocked = computed(() =>
isSendingMessage.value ||
isInterruptingTurn.value ||
Expand Down Expand Up @@ -1972,6 +2025,33 @@ function onSelectThread(threadId: string): void {
if (isMobile.value) setSidebarCollapsed(true)
}

function onSelectAutomationInPanel(automationId: string): void {
if (route.name !== 'automations') return
if (routeAutomationId.value === automationId) return
void router.replace({ name: 'automations', query: automationId ? { automationId } : {} })
}

async function onEditAutomationFromPanel(payload: AutomationEditRequest): Promise<void> {
if (isSidebarCollapsed.value) {
setSidebarCollapsed(false)
await nextTick()
}
sidebarThreadTreeRef.value?.openAutomationEditorFromPanel(payload)
}

async function onCreateAutomationFromPanel(): Promise<void> {
if (isSidebarCollapsed.value) {
setSidebarCollapsed(false)
await nextTick()
}
sidebarThreadTreeRef.value?.openAutomationCreatorFromPanel()
}

function onAutomationsChanged(): void {
if (route.name !== 'automations') return
void automationsPanelRef.value?.loadAutomations()
}

async function onExportThread(threadId: string): Promise<void> {
if (!threadId) return
if (selectedThreadId.value !== threadId) {
Expand Down Expand Up @@ -2323,6 +2403,14 @@ function getProjectCwd(projectName: string): string {
return resolvePreferredLocalCwd(projectName, projectGroup?.threads[0]?.cwd?.trim() ?? '')
}

const projectCwdByName = computed<Record<string, string>>(() =>
Object.fromEntries(
projectGroups.value
.map((group) => [group.projectName, getProjectCwd(group.projectName).trim()] as const)
.filter(([, cwd]) => cwd.length > 0),
),
)

function getProjectDisplayNameForWorktree(projectName: string): string {
return (projectDisplayNameById.value[projectName] ?? projectName).trim() || projectName
}
Expand Down Expand Up @@ -3426,7 +3514,7 @@ function onImplementPlan(payload: { turnId: string }): void {


function onExportChat(): void {
if (isHomeRoute.value || isSkillsRoute.value || typeof document === 'undefined') return
if (isHomeRoute.value || isSkillsRoute.value || isAutomationsRoute.value || typeof document === 'undefined') return
if (!selectedThread.value || filteredMessages.value.length === 0) return
const markdown = buildThreadMarkdown()
const fileName = buildExportFileName()
Expand Down Expand Up @@ -3882,7 +3970,7 @@ async function syncThreadSelectionWithRoute(): Promise<void> {
do {
hasPendingRouteSync = false

if (route.name === 'home' || route.name === 'skills') {
if (route.name === 'home' || route.name === 'skills' || route.name === 'automations') {
if (selectedThreadId.value !== '') {
await selectThread('')
}
Expand Down Expand Up @@ -3949,7 +4037,7 @@ watch(
async (threadId) => {
if (!hasInitialized.value) return
if (isRouteSyncInProgress.value) return
if (isHomeRoute.value || isSkillsRoute.value) return
if (isHomeRoute.value || isSkillsRoute.value || isAutomationsRoute.value) return

if (!threadId) {
if (route.name !== 'home') {
Expand Down Expand Up @@ -4274,6 +4362,10 @@ async function loadWorktreeBranches(sourceCwd: string): Promise<void> {
@apply flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-emerald-600 text-white;
}

.sidebar-automations-link-icon {
@apply bg-amber-500;
}

.sidebar-skills-link-icon :deep(svg) {
@apply h-5 w-5;
}
Expand All @@ -4298,6 +4390,10 @@ async function loadWorktreeBranches(sourceCwd: string): Promise<void> {
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-emerald-600 text-white shadow-[0_16px_32px_-20px_rgba(5,150,105,0.9)];
}

.automations-route-header-icon {
@apply bg-amber-500 shadow-[0_16px_32px_-20px_rgba(245,158,11,0.9)];
}

.skills-route-header-icon :deep(svg) {
@apply h-4.5 w-4.5;
}
Expand Down
Loading