Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
3 changes: 3 additions & 0 deletions src/api/codexGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export type WorkspaceRootsState = {
labels: Record<string, string>
active: string[]
projectOrder: string[]
pinnedProjectIds?: string[]
remoteProjects?: Array<{
id: string
hostId: string
Expand Down Expand Up @@ -2242,6 +2243,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 +2353,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
17 changes: 17 additions & 0 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 @@ -828,6 +831,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 +847,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 @@ -1821,6 +1826,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 +1908,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
30 changes: 30 additions & 0 deletions src/composables/useDesktopState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,36 @@ describe('filterGroupsByWorkspaceRoots', () => {
])
})

it('places Codex pinned projects before regular project order', () => {
const groups: UiProjectGroup[] = [
{
projectName: 'alpha',
threads: [thread('alpha-chat', '/tmp/alpha')],
},
{
projectName: 'beta',
threads: [thread('beta-chat', '/tmp/beta')],
},
{
projectName: 'gamma',
threads: [thread('gamma-chat', '/tmp/gamma')],
},
]
const rootsState: WorkspaceRootsState = {
order: ['/tmp/alpha', '/tmp/beta', '/tmp/gamma'],
labels: {},
active: ['/tmp/alpha'],
projectOrder: ['/tmp/beta', '/tmp/alpha', '/tmp/gamma'],
pinnedProjectIds: ['/tmp/gamma'],
}

expect(filterGroupsByWorkspaceRoots(groups, rootsState).map((group) => group.projectName)).toEqual([
'gamma',
'beta',
'alpha',
])
})

it('keeps empty duplicate workspace roots visible in Codex project order', () => {
const groups: UiProjectGroup[] = [
{
Expand Down
Loading