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
17 changes: 14 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,20 @@
- 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.
- After creating any PR, always wait for Qodo to finish before reporting the PR workflow as complete:
1. Poll the PR with `gh pr view <pr-number> --json comments,reviews,reviewDecision,mergeStateStatus,statusCheckRollup`.
2. If the Qodo code-review comment says `Check back in a few minutes`, `is analyzing this pull request`, or otherwise looks like a placeholder, sleep 30 seconds and poll again.
3. Continue polling until Qodo posts a completed code-review comment with active counts such as `Bugs (N)`, `Rule violations (N)`, or `Requirement gaps (N)`.
4. Do not treat the separate Qodo summary/walkthrough comment as the completed code review.
- When Qodo reports findings:
1. Inspect each finding and classify it as accepted, stale/already fixed, rejected/incorrect, or needs follow-up.
2. For each accepted bug, inspect the code path and add or update a focused regression test where feasible.
3. Implement the fix, then run the narrow regression test plus the relevant build/typecheck command.
4. Commit the fix as its own discrete commit and push it to the PR branch.
5. Poll Qodo again using the same 30-second loop until the updated review is complete.
6. Repeat until Qodo reports `Bugs (0)`, `Rule violations (0)`, and `Requirement gaps (0)`, or until all remaining findings are explicitly documented as stale/rejected with code evidence.
- After pushing any commit to an open PR, run the same Qodo wait-and-fix loop before reporting the push workflow as complete.
- In the completion report, include the PR URL, latest commit, commands run, Qodo final counts, and which bot findings were fixed versus stale/rejected.

## Performance Audit Rule (MANDATORY)

Expand Down
11 changes: 11 additions & 0 deletions llm-wiki/raw/features/sidebar-project-pinning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Source: Sidebar Project Pinning

Date: 2026-05-09

The sidebar now mirrors Codex.app project pinning. Codex.app exposes `Pin project` from each project row action menu and persists the selection in `~/.codex/.codex-global-state.json` under `pinned-project-ids`.

The web bridge reads and writes this key through `/codex-api/workspace-roots-state` as `pinnedProjectIds`. Existing workspace root fields remain preserved when pinning changes: `electron-saved-workspace-roots`, `electron-workspace-root-labels`, `active-workspace-roots`, `project-order`, and `remote-projects`.

Pinned projects are rendered before regular projects while preserving the pinned order. Non-pinned projects continue to follow Codex `project-order`. Duplicate leaf-name projects are resolved through the same full-path disambiguation used for workspace roots, and remote projects keep their remote project id as the pinned id.

The project action menu now shows `Pin project` or `Unpin project` depending on the current pinned state. Pinning does not rewrite manual project order; it only updates `pinned-project-ids`.
25 changes: 25 additions & 0 deletions llm-wiki/wiki/concepts/sidebar-project-pinning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Sidebar Project Pinning

Sidebar project pinning follows Codex.app global state instead of treating pinning as a local-only reorder.

## Behavior

- Project row actions include `Pin project` for unpinned projects and `Unpin project` for pinned projects.
- Pinned projects render before regular projects.
- Pinned project order follows `pinned-project-ids`.
- Regular project order continues to follow `project-order`.
- Pinning preserves the existing workspace-root state fields and only changes `pinned-project-ids`.

## State

Codex.app stores pinned project ids in `~/.codex/.codex-global-state.json` under `pinned-project-ids`. The web bridge exposes that key as `pinnedProjectIds` in `/codex-api/workspace-roots-state`.

Local projects use the workspace root path as the durable pinned id. Remote projects use the remote project id. Duplicate folder names keep using the existing full-path project disambiguation before matching pinned rows.

## Verification Notes

Manual verification should check both light and dark themes because this feature changes the project row action menu. A focused unit test should assert that a pinned project appears before the rest of the Codex `project-order`.

## Sources

- [Sidebar project pinning source](../../raw/features/sidebar-project-pinning.md)
2 changes: 2 additions & 0 deletions llm-wiki/wiki/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
- [concepts/merge-to-main-workflow.md](./concepts/merge-to-main-workflow.md): branch integration and conflict-resolution workflow.
- [concepts/opencode-zen-big-pickle.md](./concepts/opencode-zen-big-pickle.md): OpenCode Zen Big Pickle model configuration for Codex CLI and OpenCode CLI.
- [concepts/realtime-chat-rendering.md](./concepts/realtime-chat-rendering.md): realtime chat rendering, sync-churn reduction, and inline media sanitization.
- [concepts/sidebar-project-pinning.md](./concepts/sidebar-project-pinning.md): Codex.app-style project pinning state, ordering, and sidebar menu behavior.
- [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.

## 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/sidebar-project-pinning.md](../raw/features/sidebar-project-pinning.md): source facts for Codex.app-style sidebar project pinning.
- [../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/projects/codex-web-local.md](../raw/projects/codex-web-local.md): immutable source snapshot for project facts.
Expand Down
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-09] ingest | sidebar project pinning
- Added source: `raw/features/sidebar-project-pinning.md`.
- Created wiki page: `concepts/sidebar-project-pinning.md`.
- Documents: Codex.app `pinned-project-ids` state, project menu pin/unpin behavior, pinned ordering before `project-order`, and workspace-root preservation.
- Updated `index.md`.
14 changes: 12 additions & 2 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@

<SidebarThreadTree :groups="projectGroups" :project-display-name-by-id="projectDisplayNameById"
:project-git-repo-by-name="projectGitRepoByName"
:pinned-project-names="pinnedProjectNames"
v-if="!isSidebarCollapsed"
:selected-thread-id="selectedThreadId" :is-loading="isLoadingThreads"
:search-query="sidebarSearchQuery"
Expand All @@ -71,6 +72,7 @@
@browse-thread-files="onBrowseThreadFiles"
@browse-project-files="onBrowseProjectFiles"
@request-project-git-status="onRequestProjectGitStatus"
@set-project-pinned="onSetProjectPinned"
@create-project-worktree="onCreateProjectWorktree"
@rename-thread="onRenameThread"
@fork-thread="onForkThread"
Expand Down Expand Up @@ -1179,6 +1181,7 @@ const WHISPER_LANGUAGES: Record<string, string> = {

const {
projectGroups,
pinnedProjectNames,
projectDisplayNameById,
selectedThread,
selectedThreadTokenUsage,
Expand Down Expand Up @@ -1230,6 +1233,7 @@ const {
removeProject,
reorderProject,
pinProjectToTop,
setProjectPinned,
startPolling,
stopPolling,
primeSelectedThread,
Expand Down Expand Up @@ -1263,10 +1267,11 @@ const gitRepoStatusRequestByCwd = new Map<string, Promise<boolean>>()
const newWorktreeBaseBranch = ref('')
const worktreeBranchOptions = ref<WorktreeBranchOption[]>([])
const isLoadingWorktreeBranches = ref(false)
const workspaceRootOptionsState = ref<{ order: string[]; labels: Record<string, string>; projectOrder: string[] }>({
const workspaceRootOptionsState = ref<{ order: string[]; labels: Record<string, string>; projectOrder: string[]; pinnedProjectIds: string[] }>({
order: [],
labels: {},
projectOrder: [],
pinnedProjectIds: [],
})
const worktreeInitStatus = ref<{ phase: 'idle' | 'running' | 'error'; title: string; message: string }>({
phase: 'idle',
Expand Down Expand Up @@ -2438,6 +2443,10 @@ function onReorderProject(payload: { projectName: string; toIndex: number }): vo
reorderProject(payload.projectName, payload.toIndex)
}

function onSetProjectPinned(payload: { projectName: string; pinned: boolean }): void {
void setProjectPinned(payload.projectName, payload.pinned)
}

function onRequestProjectGitStatus(projectName: string): void {
const group = projectGroups.value.find((entry) => entry.projectName === projectName)
const cwd = resolvePreferredLocalCwd(projectName, group?.threads[0]?.cwd?.trim() ?? '')
Expand Down Expand Up @@ -3283,9 +3292,10 @@ async function loadWorkspaceRootOptionsState(): Promise<void> {
order: [...state.order],
labels: { ...state.labels },
projectOrder: [...state.projectOrder],
pinnedProjectIds: [...(state.pinnedProjectIds ?? [])],
}
} catch {
workspaceRootOptionsState.value = { order: [], labels: {}, projectOrder: [] }
workspaceRootOptionsState.value = { order: [], labels: {}, projectOrder: [], pinnedProjectIds: [] }
}
}

Expand Down
35 changes: 34 additions & 1 deletion src/api/codexGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import type {
UiReviewWorkspaceView,
UiRateLimitSnapshot,
UiRateLimitWindow,
UiThread,
UiThreadAutomation,
UiThreadAutomationStatus,
} from '../types/codex'
Expand Down Expand Up @@ -268,6 +269,7 @@ export type WorkspaceRootsState = {
labels: Record<string, string>
active: string[]
projectOrder: string[]
pinnedProjectIds?: string[]
remoteProjects?: Array<{
id: string
hostId: string
Expand Down Expand Up @@ -753,6 +755,31 @@ export async function getThreadGroupsPage(
}
}

export async function getThreadSummariesByIds(threadIds: string[]): Promise<UiThread[]> {
const normalizedThreadIds = threadIds
.map((threadId) => threadId.trim())
.filter((threadId, index, rows) => threadId.length > 0 && rows.indexOf(threadId) === index)

if (normalizedThreadIds.length === 0) return []

const summaries = await Promise.all(
normalizedThreadIds.map(async (threadId) => {
try {
const payload = await callRpc<ThreadReadResponse>('thread/read', {
threadId,
includeTurns: false,
})
const groups = normalizeThreadGroupsV2({ data: [payload.thread] } as ThreadListResponse)
return groups.flatMap((group) => group.threads)[0] ?? null
} catch {
return null
}
}),
)

return summaries.filter((thread): thread is UiThread => thread !== null)
}

export function getBackgroundThreadListLimit(): number {
return BACKGROUND_THREAD_LIST_LIMIT
}
Expand Down Expand Up @@ -2242,6 +2269,7 @@ function normalizeWorkspaceRootsState(payload: unknown): WorkspaceRootsState {
labels,
active: normalizeArray(record.active).map((value) => normalizePathForUi(value)),
projectOrder: normalizeArray(record.projectOrder).map((value) => normalizePathForUi(value)),
pinnedProjectIds: normalizeArray(record.pinnedProjectIds).map((value) => normalizePathForUi(value)),
remoteProjects: Array.isArray(record.remoteProjects)
? record.remoteProjects.flatMap((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return []
Expand Down Expand Up @@ -2351,6 +2379,7 @@ function cloneWorkspaceRootsState(state: WorkspaceRootsState): WorkspaceRootsSta
labels: { ...state.labels },
active: [...state.active],
projectOrder: [...state.projectOrder],
pinnedProjectIds: [...(state.pinnedProjectIds ?? [])],
remoteProjects: state.remoteProjects?.map((item) => ({ ...item })) ?? [],
}
}
Expand Down Expand Up @@ -2728,6 +2757,7 @@ async function readJsonResponse(response: Response): Promise<unknown> {
}

export async function setWorkspaceRootsState(nextState: WorkspaceRootsState): Promise<void> {
const previousState = cachedWorkspaceRootsState
const response = await fetch('/codex-api/workspace-roots-state', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -2736,7 +2766,10 @@ export async function setWorkspaceRootsState(nextState: WorkspaceRootsState): Pr
if (!response.ok) {
throw new Error('Failed to save workspace roots state')
}
cachedWorkspaceRootsState = cloneWorkspaceRootsState(nextState)
cachedWorkspaceRootsState = cloneWorkspaceRootsState({
...nextState,
remoteProjects: nextState.remoteProjects ?? previousState?.remoteProjects ?? [],
})
}

export async function openProjectRoot(path: string, options?: { createIfMissing?: boolean; label?: string }): Promise<string> {
Expand Down
42 changes: 37 additions & 5 deletions src/components/sidebar/SidebarThreadTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@
<button class="project-menu-item" type="button" @click="onBrowseProjectFiles(group.projectName)">
Browse files
</button>
<button class="project-menu-item" type="button" @click="onToggleProjectPinned(group.projectName)">
{{ isProjectPinned(group.projectName) ? 'Unpin project' : 'Pin project' }}
</button>
<button
v-if="projectGitRepoByName[group.projectName]"
class="project-menu-item"
Expand Down Expand Up @@ -806,6 +809,7 @@ import type { ComponentPublicInstance } from 'vue'
import {
deleteThreadAutomation,
getPinnedThreadState,
getThreadSummariesByIds,
getThreadAutomationMap,
persistPinnedThreadIds,
runThreadAutomationNow,
Expand All @@ -828,6 +832,7 @@ const props = defineProps<{
groups: UiProjectGroup[]
projectDisplayNameById: Record<string, string>
projectGitRepoByName: Record<string, boolean>
pinnedProjectNames: string[]
selectedThreadId: string
isLoading: boolean
searchQuery: string
Expand All @@ -843,6 +848,7 @@ const emit = defineEmits<{
'browse-thread-files': [threadId: string]
'browse-project-files': [projectName: string]
'request-project-git-status': [projectName: string]
'set-project-pinned': [payload: { projectName: string; pinned: boolean }]
'create-project-worktree': [projectName: string]
'rename-project': [payload: { projectName: string; displayName: string }]
'rename-thread': [payload: { threadId: string; title: string }]
Expand Down Expand Up @@ -909,6 +915,7 @@ const showChatsFirst = ref(loadBooleanStorage(CHATS_FIRST_STORAGE_KEY, false))
const chatSortMode = ref<ChatSortMode>(loadChatSortMode())
let hasLoadedPinnedThreadState = false
const pinnedThreadIds = ref<string[]>([])
const pinnedThreadFallbackById = ref<Record<string, UiThread>>({})
const inlineDeleteConfirmThreadId = ref('')
const optimisticallyArchivedThreadIds = ref<string[]>([])
const openProjectMenuId = ref('')
Expand Down Expand Up @@ -1150,6 +1157,11 @@ const hasHiddenChatThreads = computed(() => {
const threadById = computed(() => {
const map = new Map<string, UiThread>()

for (const thread of Object.values(pinnedThreadFallbackById.value)) {
if (optimisticallyArchivedThreadIdSet.value.has(thread.id)) continue
map.set(thread.id, thread)
}

for (const group of props.groups) {
for (const thread of group.threads) {
if (optimisticallyArchivedThreadIdSet.value.has(thread.id)) continue
Expand All @@ -1168,11 +1180,18 @@ watch(
},
)

watch(threadById, (threadsById) => {
const filtered = pinnedThreadIds.value.filter((threadId) => threadsById.has(threadId))
if (filtered.length === pinnedThreadIds.value.length) return
pinnedThreadIds.value = filtered
})
async function hydratePinnedThreadFallbacks(threadIds: string[]): Promise<void> {
const missingThreadIds = threadIds.filter((threadId) => !threadById.value.has(threadId))
if (missingThreadIds.length === 0) return

const threads = await getThreadSummariesByIds(missingThreadIds)
if (threads.length === 0) return

pinnedThreadFallbackById.value = {
...pinnedThreadFallbackById.value,
...Object.fromEntries(threads.map((thread) => [thread.id, thread])),
}
}

onMounted(async () => {
const { threadIds } = await getPinnedThreadState()
Expand All @@ -1185,6 +1204,7 @@ onMounted(async () => {

if (normalized.length > 0) {
pinnedThreadIds.value = normalized
await hydratePinnedThreadFallbacks(normalized)
}
try {
automationByThreadId.value = await getThreadAutomationMap()
Expand Down Expand Up @@ -1821,6 +1841,10 @@ function isProjectMenuOpen(projectName: string): boolean {
return openProjectMenuId.value === projectName
}

function isProjectPinned(projectName: string): boolean {
return props.pinnedProjectNames.includes(projectName)
}

function closeProjectMenu(): void {
openProjectMenuId.value = ''
projectMenuMode.value = 'actions'
Expand Down Expand Up @@ -1899,6 +1923,14 @@ function onBrowseProjectFiles(projectName: string): void {
closeProjectMenu()
}

function onToggleProjectPinned(projectName: string): void {
emit('set-project-pinned', {
projectName,
pinned: !isProjectPinned(projectName),
})
closeProjectMenu()
}

function onCreateProjectWorktree(projectName: string): void {
emit('create-project-worktree', projectName)
closeProjectMenu()
Expand Down
Loading