Skip to content

fix(web): restore manual sort drag and keep per-group expand state#2221

Merged
juliusmarminge merged 5 commits intopingdotgg:mainfrom
mwolson:fix/manual-sort-drag-drop-regression-2055
Apr 20, 2026
Merged

fix(web): restore manual sort drag and keep per-group expand state#2221
juliusmarminge merged 5 commits intopingdotgg:mainfrom
mwolson:fix/manual-sort-drag-drop-regression-2055

Conversation

@mwolson
Copy link
Copy Markdown
Contributor

@mwolson mwolson commented Apr 20, 2026

Summary

Fixes #2219.

Repro

  1. In the sidebar, click the "Sort projects" icon (arrows-up-down, next to the sidebar header) and set Sort projects to Manual. Drag a project. It snaps back.
  2. In the same menu, set Group projects to Group by repository. Collapse a grouped row. Trigger anything that re-enters syncProjects (e.g. a project refresh). It re-expands.

Diagnosis

apps/web/src/uiStateStore.ts tracks projectOrder and projectExpandedById. PR #2055 changed the writers to use physical keys (env + normalized cwd) but did not update the sibling readers.

For projectOrder:

  • Writer: handleProjectDragEnd in Sidebar.tsx:2857 passes member.physicalProjectKey to reorderProjects (see lines 2870-2873).
  • Reader: orderedProjects memo in Sidebar.tsx:2707 and useHandleNewThread.ts:168 both use getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)).

For projectExpandedById:

  • Writer: syncProjects seeded entries keyed by the physical key.
  • Reader: the sidebar row computes its state from the logical (group) key, which in non-separate grouping modes is the repo canonical key, not the physical key.

Why this approach

For projectOrder, I extracted a named helper so any future caller writing projectOrder can be pinned to the same derivation as the readers:

// apps/web/src/logicalProject.ts
export function getProjectOrderKey(project: Pick<Project, "environmentId" | "cwd">): string {
  return derivePhysicalProjectKey(project);
}

Both orderedProjects and useHandleNewThread now use getProjectOrderKey as their getId, matching the drag-end writer. That way the next silent divergence becomes a grep target, rather than a deploy-day regression.

For projectExpandedById, I split SyncProjectInput into a physical key (used for projectOrder, stable across grouping changes) and a logicalKey (used for expand/collapse, matches what the UI reads). syncProjects now keys projectExpandedById by logicalKey, and localStorage persistence tracks the member cwds per group so hydration still works for grouped projects (no migration needed).

syncProjects also preserves expand state when a project's logical key changes mid-session -- for example, late-arriving repo metadata flipping the grouping identity from the physical key to the repo canonical key. Without that, the row would unexpectedly reopen the instant metadata arrived.

Considered alternatives

Rolling back #2055's writer-side choice would also solve regression A, but #2055 keyed persistence by cwd to survive project id churn, which is the right direction. Aligning the readers (and disentangling the two pieces of state) is the smaller, forward-compatible fix.

Known limitation

If the user toggles sidebarProjectGroupingMode at runtime without any project snapshot events following, syncProjects does not re-run and existing projectExpandedById entries stay keyed under the previous grouping until the next project event arrives. Today the most common flow triggers a sync before the user notices, but a subscriber that re-runs syncProjectUiFromStore when grouping settings change would make this tighter. I left that out to keep this PR small; happy to fold it in if you prefer.

Test plan

  • bun fmt
  • bun lint
  • bun typecheck
  • bun run test (938 tests pass, 1 file skipped, no new failures)
  • New regression tests added:
    • apps/web/src/components/Sidebar.logic.test.ts: asserts getProjectOrderKey is what orderItemsByPreferredIds uses to honor projectOrder (locks in the snap-back fix).
    • apps/web/src/uiStateStore.test.ts: syncProjects keys projectExpandedById by the logical key, not the physical key (two physical projects sharing a logical group both respect a collapsed logical-key entry).
    • apps/web/src/uiStateStore.test.ts: syncProjects preserves expand state when a project's logical key changes (covers the late-metadata flip).
  • End-to-end in the Linux AppImage build:
    • Set Sort projects to Manual and dragged several projects into new positions (rows that used to snap back). They stayed put.
    • With Group projects set to Group by repository, collapsed several grouped rows.
    • Restarted t3code and confirmed both the manual order and the collapsed states persisted across the restart.

Note

Medium Risk
Touches sidebar ordering and uiStateStore persistence/keying logic; regressions could affect project ordering/expand state across sessions, but scope is contained to client UI state.

Overview
Restores manual project drag ordering by making sidebar readers (Sidebar, useHandleNewThread) identify projects using the same physical key (environmentId + normalized cwd) that drag handlers/store write, via new getProjectOrderKey.

Refactors project UI state syncing/persistence to support grouped projects: syncProjects now distinguishes physical key (ordering) from logicalKey (expand/collapse), preserves expand state when logical keys change, and persists both collapsedProjectCwds and ordering cwds to avoid “everything re-expands” and order loss on restart. Adds a non-hook getClientSettings() accessor so runtime services can compute logicalKey during project sync.

Adds regression tests covering physical-key ordering, logical-key expand state, logical-key churn, and persistence round-trips for collapse/order.

Reviewed by Cursor Bugbot for commit fb544d9. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Fix manual sort drag and per-group expand/collapse state persistence in sidebar

  • Introduces getProjectOrderKey in logicalProject.ts as the canonical key for project sort order, then updates Sidebar.tsx and useHandleNewThread.ts to use it, preventing manual drag order from snapping back.
  • Reworks syncProjects in uiStateStore.ts to key expand/collapse state by logical group rather than physical project id, preserving collapsed rows across project id churn and restarts.
  • Adds collapsedProjectCwds to the persisted state shape and updates persistState/hydratePersistedProjectState so an all-collapsed sidebar remains collapsed after a page reload.
  • Passes logicalKey (derived via deriveLogicalProjectKeyFromSettings) into syncProjects from service.ts to support grouping-aware expand state.
  • Behavioral Change: legacy persisted state (without collapsedProjectCwds) defaults all projects to collapsed for one session to avoid ambiguity, then writes the new format on next persist.

Macroscope summarized fb544d9.

mwolson added 2 commits April 19, 2026 23:40
pingdotgg#2055 silently regressed two sidebar behaviors by changing how projects
are keyed in `uiStateStore`:

- Manual-sort drag reorder snapped projects back to their original
  position. Writers populated `projectOrder` with physical keys
  (env + cwd) but readers matched items by scoped keys (env + project
  id), so `preferredIds` never matched and manual order was discarded.
- Expand/collapse state was wiped whenever projects were grouped
  (grouping != `separate`), because `syncProjects` seeded
  `projectExpandedById` by physical key while the UI read and wrote it
  by logical (group) key.

Route readers and writers through a single source of truth:

- `getProjectOrderKey` centralizes the physical-key derivation used by
  `projectOrder` so future callers cannot silently drift again.
- `SyncProjectInput` now carries both a physical `key` (sort order) and
  a `logicalKey` (expand/collapse). `syncProjects` keys
  `projectExpandedById` by `logicalKey`, and persistence tracks the
  member cwds per group so localStorage hydration still works for
  grouped projects.
When late-arriving project metadata flips the grouping identity for a
project (e.g. physical key to repository canonical key), the row itself
did not change but its entry in `projectExpandedById` moved to a new
key. Before this change, we fell through to the persisted-cwds
fallback, which often meant silently defaulting the row back to
expanded.

Track the previous logical key per physical key across syncs, then if a
project's new logical key is unseen, fall back to the previous logical
key's expand state before touching the persisted fallback.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 20, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 68ccae6a-61d9-4da0-a0a9-63ee74b22311

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Apr 20, 2026
macroscopeapp[bot]
macroscopeapp bot previously approved these changes Apr 20, 2026
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 20, 2026

Approvability

Verdict: Needs human review

This bug fix introduces non-trivial changes to sidebar project state management, including new keying strategies (physical vs logical keys), persistence format migration, and complex fallback logic for preserving expand state across restarts. The extensive new tests demonstrate the scope of behavioral changes. Given the complexity and the author being new to this code, human review is warranted.

You can customize Macroscope's approvability policy. Learn more.

mwolson added 3 commits April 20, 2026 01:39
When the user collapsed every sidebar project row, the next launch
re-expanded them all. `persistState` only wrote cwds of expanded projects,
and an empty `expandedProjectCwds` array on rehydrate is indistinguishable
from a fresh install, so the `syncProjects` fallback returned `true`.

Persist `collapsedProjectCwds` alongside `expandedProjectCwds` so the
fallback can tell "user collapsed everything" apart from "no info yet".

For one session after upgrade, sessions whose persisted blob predates
this field keep the old "not in expanded list = collapsed" semantic so
nothing flips on the first restart; the next persistState writes the new
shape and the legacy branch goes dormant.
Locks in the persistState/hydrate/syncProjects contract behind the
collapsed/expanded fix:

- All-collapsed state survives a restart (the original regression).
- Mixed expand state is preserved and a brand-new project defaults to
  expanded under the new shape.
- Legacy "not in expandedProjectCwds = collapsed" semantic is preserved
  for one upgrade session when collapsedProjectCwds is missing.

Exports PERSISTED_STATE_KEY, PersistedUiState, hydratePersistedProjectState,
and persistState so the tests can drive a deterministic localStorage
round-trip without spinning up the zustand store.
Closes coverage gaps Codex flagged in the prior commit:

- projectOrderCwds round-trip: reorder, persist, hydrate, sync, and
  assert the manual order survives. Catches regressions where ordering
  persistence breaks while the expand-state tests still pass.
- Restart with logical-key change: covers the persisted-cwd fallback in
  syncProjects, which is the only path that can carry collapse state
  across a restart when a project also moves into a new logical group.
  The existing in-memory pure-function test does not exercise this
  because it only covers same-session migration.
@macroscopeapp macroscopeapp bot dismissed their stale review April 20, 2026 05:39

Dismissing prior approval to re-evaluate fb544d9

@mwolson
Copy link
Copy Markdown
Contributor Author

mwolson commented Apr 20, 2026

Pushed three more commits that fix a related pre-existing bug. Also closes #1910.

The bug

Pre-existing (not caused by this PR's grouping refactor): collapse all sidebar projects, restart the app, and they all re-expand. Root cause is that persistState only wrote expandedProjectCwds, so an empty array on rehydrate was indistinguishable from a fresh install. The syncProjects fallback then defaulted everything to expanded.

The fix (803cf3a7)

Adds a parallel collapsedProjectCwds array. A persistedProjectStateUsesLegacyShape flag captured at hydrate time keeps the pre-fix "not-in-expanded-list = collapsed" semantic for one session after upgrade, so users who'd already collapsed rows under the old shape don't see them pop open before the first debounced write rewrites in the new shape.

Tests (cbf34237, fb544d9d)

Five new round-trip tests in apps/web/src/uiStateStore.test.ts:

  • All-collapsed survives restart (the regression).
  • Mixed expand state survives, and new projects default to expanded under the new shape.
  • Legacy upgrade fallback works for one session.
  • projectOrderCwds round-trips.
  • Restart + logical-key change preserves collapse via the persisted-cwd fallback (a path the existing in-memory test can't reach).

To keep the tests deterministic without booting the zustand store, this exports four module-private symbols: PERSISTED_STATE_KEY, PersistedUiState, hydratePersistedProjectState, persistState.

Manually verified in the Linux AppImage: mix preserved across restart, all-collapsed preserved across restart.

Why this rather than #1923

#1923 addresses the same user-visible symptom with a sidebarProjectsDefaultExpanded setting that changes the no-persisted-state default to collapsed. That hides the symptom, but leaves the underlying persistence ambiguity in place — "everything collapsed" still looks identical to "no preference yet" on disk, so collapse intent isn't faithfully round-tripped.

This PR fixes the root cause instead:

  • Collapse intent persists faithfully, with no new setting to document or maintain.
  • Users bitten by [Feature]: Default projects to be collapsed or open setting #1910 stop seeing the issue, with no behavior change for users who weren't bitten (new projects still default to expanded).
  • If a configurable default for genuinely-new projects is still wanted later, it can land cleanly on top of this without conflating "collapsed" and "unknown."

@juliusmarminge juliusmarminge merged commit de05b0c into pingdotgg:main Apr 20, 2026
12 checks passed
aaditagrawal added a commit to aaditagrawal/t3code that referenced this pull request Apr 20, 2026
Upstream additions:
- fix(web): restore manual sort drag and keep per-group expand state (pingdotgg#2221)
- fix: Change right panel sheet to be below title bar / action bar (pingdotgg#2224)
- Refactor OpenCode lifecycle and structured output handling (pingdotgg#2218)
- effect-codex-app-server (pingdotgg#1942)
- Redesign model picker with favorites and search (pingdotgg#2153)
- fix(server): prevent probeClaudeCapabilities from wasting API requests (pingdotgg#2192)
- fix(server): handle OpenCode text response format in commit message gen (pingdotgg#2202)
- Devcontainer / IDE updates (pingdotgg#2208)
- Expand leading ~ in Codex home paths before exporting CODEX_HOME (pingdotgg#2210)
- fix(release): use v<semver> tag format for nightly releases (pingdotgg#2186)

Fork adaptations:
- Took upstream's redesigned model picker with favorites and search
- Removed deleted codexAppServerManager (replaced by effect-codex-app-server)
- Stubbed fetchCodexUsage (manager-based readout no longer available)
- Extended PROVIDER_ICON_BY_PROVIDER for all 8 fork providers
- Extended modelOptionsByProvider test fixtures for all 8 providers
- Inline ClaudeSlashCommand type (not yet re-exported from SDK)
- Updated SettingsPanels imports for new picker module structure
- Preserved fork's CI customizations (ubuntu-24.04 not Blacksmith)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: v0.0.20+ regression: manual-sort drag reorder snaps back; grouped projects lose expand state

2 participants