Skip to content
Merged
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
214 changes: 214 additions & 0 deletions apps/desktop/plans/20260306-2140-mixed-project-children-ordering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
# Mixed project-child ordering for workspace sidebar

This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.

Reference: This plan follows conventions from `AGENTS.md` (root), `apps/desktop/AGENTS.md`, and the ExecPlan template at `.agents/commands/create-plan.md`.


## Purpose / Big Picture

The desktop workspace sidebar currently models a project's top-level children as two separate ordered lanes:

1. ungrouped workspaces
2. sections

That means users can reorder workspaces against workspaces and sections against sections, but they cannot reorder a section against an ungrouped workspace. The UI exposes this limitation as a drag-and-drop dead-end, but the real constraint is deeper: the server stores and queries these as distinct ordered collections and the sidebar always renders them in a fixed `workspaces -> sections` sequence.

After this change, a project's top-level children are treated as one ordered list consisting of:

1. ungrouped workspaces
2. sections

The important difference is that these items can now be interleaved arbitrarily by `tabOrder`. Section-internal workspaces remain separately ordered within each section.


## Assumptions

1. We can use the existing `tabOrder` columns on `workspaces` and `workspace_sections` as the persisted top-level ordering source of truth.
2. Section-contained workspaces should keep their current intra-section ordering behavior and continue using `workspaces.tabOrder`.
3. We should avoid a database migration unless the existing schema proves insufficient.
4. Keyboard shortcuts and next/previous workspace navigation must follow the same visual order the sidebar renders.


## Open Questions

1. Should the tRPC query shape returned by `getAllGrouped` gain a `topLevelItems` array, or should the renderer derive it from `workspaces` + `sections` during the transition?
Decision Log: [DL-1](#dl-1-query-shape-for-top-level-items)
2. Should we replace `reorderSections` with a new mixed top-level reorder mutation immediately, or keep `reorderSections` as a compatibility wrapper during the refactor?
Decision Log: [DL-2](#dl-2-mutation-transition-strategy)


## Progress

- [x] (2026-03-07 05:40Z) Draft ExecPlan
- [x] (2026-03-07 05:43Z) Inventory ordering assumptions across `getAllGrouped`, `computeVisualOrder`, sidebar DnD, and workspace shortcuts
- [x] (2026-03-07 05:45Z) Add shared server helper for mixed top-level project child ordering
- [x] (2026-03-07 05:45Z) Add backend tests for mixed workspace/section ordering
- [x] (2026-03-07 05:45Z) Route new top-level workspace/section creation through shared top-level child ordering
- [x] (2026-03-07 05:47Z) Move `computeVisualOrder` onto the shared mixed top-level ordering helper
- [x] (2026-03-07 06:19Z) Add `topLevelItems` to `getAllGrouped` and update keyboard shortcuts to use mixed top-level ordering
- [x] (2026-03-07 06:19Z) Replace sidebar rendering with one ordered top-level list per project
- [x] (2026-03-07 06:19Z) Replace section-only reorder mutation/UI flow with mixed top-level reorder flow
- [x] (2026-03-07 06:19Z) Update keyboard shortcut and next/previous workspace ordering
- [ ] Audit create/move paths so new items get correct top-level `tabOrder`
- [ ] Run `bun run typecheck`, targeted desktop tests, and manual sidebar DnD QA
Comment on lines +41 to +54
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

This ExecPlan is already stale.

Line 54 is still unchecked, and Lines 68-75 leave both decision logs as TBD even though this PR already adds topLevelItems and reorderProjectChildren. Since Line 3 says these sections must stay current, please update the plan before merge.

Also applies to: 66-75

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/plans/20260306-2140-mixed-project-children-ordering.md` around
lines 41 - 54, The ExecPlan is stale: update the checklist to reflect completed
work (check the remaining box for running typecheck/QA or mark appropriately)
and fill in the decision logs (previously TBD) to reflect that `topLevelItems`
was added to `getAllGrouped` and `reorderProjectChildren` (and that
`computeVisualOrder` was moved onto the shared mixed top-level ordering helper),
and confirm how new items should receive `tabOrder`; ensure lines referencing
`topLevelItems`, `reorderProjectChildren`, `getAllGrouped`, and
`computeVisualOrder` are updated to the current state and remove or adjust any
outstanding TODOs before merging.



## Surprises & Discoveries

- `getAllGrouped` currently returns separate `group.workspaces` and `group.sections`, and many renderer callsites assume the visual order is always “ungrouped workspaces first, then all sections”.
- `getWorkspacesInVisualOrder` in `apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts` also hardcodes that same two-lane ordering, so keyboard navigation and sidebar DnD semantics are coupled to the query shape.
- The existing creation paths were already inconsistent with the desired model: new sections appended relative only to sections, while new top-level workspaces appended relative only to workspaces. That had to be unified before mixed reordering could behave predictably.
- Desktop typecheck surfaced an unrelated-but-live issue in the collapsed section drag-handle refs. That wiring needed a callback-ref fix before the package would typecheck cleanly.
- Once the sidebar started rendering from `topLevelItems`, several optimistic cache writers (`close`, `delete`) needed to remove entries from both `sections[*].workspaces` and `topLevelItems`. Those optimistic paths were previously incomplete for section-contained workspaces too.


## Decision Log

### DL-1 Query shape for top-level items

TBD. The cleanest long-term shape is likely a `topLevelItems` array on each project group, but that is a broader API change. A staged migration can keep `workspaces` and `sections` in the query while deriving a mixed array in the renderer first.


### DL-2 Mutation transition strategy

TBD. A new mixed reorder mutation is the right end state. We may temporarily keep `reorderSections` as a wrapper or stop using it from the sidebar as soon as the new mutation exists.


## Outcomes & Retrospective

TBD after implementation.


## Context and Orientation

Relevant files:

- `apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts` builds the grouped sidebar data and the visual order used for next/previous workspace navigation.
- `apps/desktop/src/lib/trpc/routers/workspaces/procedures/sections.ts` owns section creation, section reordering, and moving workspaces in/out of sections.
- `apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts` assigns `tabOrder` when new workspaces are created.
- `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx` currently renders ungrouped workspaces first and sections second.
- `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSection/WorkspaceSection.tsx` implements section drag-and-drop against other sections only.
- `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/useWorkspaceDnD.ts` implements workspace drag-and-drop within ungrouped and in-section lanes.
- `apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts` flattens workspaces using the old grouped ordering assumptions.

Current persisted ordering model:

- Top-level ungrouped workspaces use `workspaces.tabOrder`
- Sections use `workspace_sections.tabOrder`
- In-section workspaces also use `workspaces.tabOrder`, but only relative to other workspaces in the same section

Current rendering model:

project
- group.workspaces sorted by tabOrder
- group.sections sorted by tabOrder
- section.workspaces sorted by tabOrder

Target rendering model:

project
- topLevelItems sorted by tabOrder
- workspace item
- section item
- section.workspaces sorted by tabOrder


## Plan of Work

### Milestone 1: Lock down a mixed top-level ordering helper

Add a server-side helper that can load a project's top-level children as one list, reorder them, and persist normalized `tabOrder` values back to both `workspaces` and `workspace_sections`.

Scope:

1. Add a utility under `apps/desktop/src/lib/trpc/routers/workspaces/utils/` for:
- collecting ungrouped workspaces and sections for one project
- sorting them by `tabOrder`
- moving one item to a target position
- writing sequential `tabOrder` values back to the correct tables
2. Keep section-internal workspace ordering out of this helper.
3. Add tests that prove a section can be moved before/after an ungrouped workspace and that ordering remains normalized.

Acceptance:

bun test apps/desktop/src/lib/trpc/routers/workspaces/utils


### Milestone 2: Introduce a mixed top-level reorder mutation

Add a new mutation for project-level child reordering and move the sidebar off `reorderSections` for top-level ordering.

Scope:

1. Add a tRPC mutation that accepts:
- `projectId`
- dragged item kind/id
- destination index within the combined top-level list
2. Reuse the helper from Milestone 1.
3. Keep `reorderWorkspacesInSection` unchanged for section contents.

Acceptance:

Backend tests cover:

1. workspace before section
2. section before workspace
3. no-op reorders
4. invalid item IDs / invalid indices


### Milestone 3: Make query and shortcut ordering match the new model

Refactor server query helpers and shortcut consumers so the visual order is based on mixed top-level project children, not a fixed workspaces-then-sections sequence.

Scope:

1. Update `getWorkspacesInVisualOrder` and `computeVisualOrder` usage to iterate projects by mixed top-level order.
2. Update `getAllGrouped` or its renderer consumers so top-level order can be rendered without assuming `group.workspaces` comes first.
3. Update `useWorkspaceShortcuts` to flatten workspaces in true visual order.

Acceptance:

1. Keyboard shortcuts match sidebar order.
2. Previous/next workspace navigation matches sidebar order.


### Milestone 4: Refactor sidebar rendering and DnD

Render one mixed top-level list per project and allow section/workspace drag-and-drop across that list.

Scope:

1. In `ProjectSection.tsx`, replace the fixed “ungrouped workspaces then sections” rendering with a mixed ordered list.
2. Add a shared top-level DnD path for project children.
3. Preserve existing section-internal workspace DnD behavior.
4. Update optimistic cache writes to reorder the mixed top-level list correctly.

Acceptance:

Manual QA:

1. Drag section above an ungrouped workspace.
2. Drag section below an ungrouped workspace.
3. Drag ungrouped workspace around sections.
4. Reorder workspaces inside a section unchanged.


### Milestone 5: Audit creation and move semantics

Ensure all mutations that create or relocate top-level items produce stable mixed ordering.

Scope:

1. New top-level workspaces should append to the mixed top-level list, not just the ungrouped workspace lane.
2. New sections should append to the mixed top-level list, not just the sections lane.
3. Moving a workspace into or out of a section should preserve sane ordering for both the top-level list and the destination section.

Acceptance:

Targeted tests cover:

1. creating a section after top-level workspaces exist
2. creating a workspace after sections exist
3. moving a workspace out of a section into top-level placement
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
findOrphanedWorktreeByBranch,
findWorktreeWorkspaceByBranch,
getBranchWorkspace,
getMaxWorkspaceTabOrder,
getMaxProjectChildTabOrder,
getProject,
getWorktree,
setLastActiveWorkspace,
Expand Down Expand Up @@ -53,7 +53,7 @@ function createWorkspaceFromWorktree({
branch,
name,
}: CreateWorkspaceFromWorktreeParams) {
const maxTabOrder = getMaxWorkspaceTabOrder(projectId);
const maxTabOrder = getMaxProjectChildTabOrder(projectId);

const workspace = localDb
.insert(workspaces)
Expand Down Expand Up @@ -469,7 +469,7 @@ export const createCreateProcedures = () => {
.returning()
.get();

const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId);
const maxTabOrder = getMaxProjectChildTabOrder(input.projectId);

const workspace = localDb
.insert(workspaces)
Expand Down Expand Up @@ -687,7 +687,7 @@ export const createCreateProcedures = () => {
throw new Error("Worktree no longer exists on disk");
}

const maxTabOrder = getMaxWorkspaceTabOrder(worktree.projectId);
const maxTabOrder = getMaxProjectChildTabOrder(worktree.projectId);

const workspace = localDb
.insert(workspaces)
Expand Down Expand Up @@ -800,7 +800,7 @@ export const createCreateProcedures = () => {
};
}

const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId);
const maxTabOrder = getMaxProjectChildTabOrder(input.projectId);
const workspace = localDb
.insert(workspaces)
.values({
Expand Down Expand Up @@ -868,7 +868,7 @@ export const createCreateProcedures = () => {
.returning()
.get();

const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId);
const maxTabOrder = getMaxProjectChildTabOrder(input.projectId);
const workspace = localDb
.insert(workspaces)
.values({
Expand Down Expand Up @@ -1013,7 +1013,7 @@ export const createCreateProcedures = () => {
const exists = await worktreeExists(project.mainRepoPath, wt.path);
if (!exists) continue;

const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId);
const maxTabOrder = getMaxProjectChildTabOrder(input.projectId);
localDb
.insert(workspaces)
.values({
Expand Down Expand Up @@ -1067,7 +1067,7 @@ export const createCreateProcedures = () => {
.returning()
.get();

const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId);
const maxTabOrder = getMaxProjectChildTabOrder(input.projectId);
localDb
.insert(workspaces)
.values({
Expand Down
35 changes: 32 additions & 3 deletions apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { localDb } from "main/lib/local-db";
import { z } from "zod";
import { publicProcedure, router } from "../../..";
import { getWorkspace } from "../utils/db-helpers";
import { getProjectChildItems } from "../utils/project-children-order";
import { computeVisualOrder } from "../utils/visual-order";
import { getWorkspacePath } from "../utils/worktree";

Expand Down Expand Up @@ -112,13 +113,20 @@ export const createQueryProcedures = () => {

type SectionItem = {
id: string;
projectId: string;
name: string;
tabOrder: number;
isCollapsed: boolean;
color: string | null;
workspaces: WorkspaceItem[];
};

type TopLevelItem = {
id: string;
kind: "workspace" | "section";
tabOrder: number;
};

const activeProjects = localDb
.select()
.from(projects)
Expand Down Expand Up @@ -147,6 +155,7 @@ export const createQueryProcedures = () => {
};
workspaces: WorkspaceItem[];
sections: SectionItem[];
topLevelItems: TopLevelItem[];
}
>();

Expand All @@ -156,6 +165,7 @@ export const createQueryProcedures = () => {
.sort((a, b) => a.tabOrder - b.tabOrder)
.map((s) => ({
id: s.id,
projectId: s.projectId,
name: s.name,
tabOrder: s.tabOrder,
isCollapsed: s.isCollapsed ?? false,
Expand All @@ -177,6 +187,7 @@ export const createQueryProcedures = () => {
},
workspaces: [],
sections: projectSections,
topLevelItems: [],
});
}

Expand Down Expand Up @@ -222,9 +233,27 @@ export const createQueryProcedures = () => {
}
}

return Array.from(groupsMap.values()).sort(
(a, b) => a.project.tabOrder - b.project.tabOrder,
);
return Array.from(groupsMap.values())
.map((group) => {
const projectWorkspaces = [
...group.workspaces,
...group.sections.flatMap((section) => section.workspaces),
];

return {
...group,
topLevelItems: getProjectChildItems(
group.project.id,
projectWorkspaces,
group.sections,
).map((item) => ({
id: item.id,
kind: item.kind,
tabOrder: item.tabOrder,
})),
};
})
.sort((a, b) => a.project.tabOrder - b.project.tabOrder);
}),

getPreviousWorkspace: publicProcedure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
} from "shared/constants/project-colors";
import { z } from "zod";
import { publicProcedure, router } from "../../..";
import { computeNextTabOrder, reorderItems } from "../utils/reorder";
import { getMaxProjectChildTabOrder } from "../utils/db-helpers";
import { reorderItems } from "../utils/reorder";

const SECTION_COLORS = PROJECT_COLORS.filter(
(c) => c.value !== PROJECT_COLOR_DEFAULT,
Expand All @@ -28,15 +29,7 @@ export const createSectionsProcedures = () => {
}),
)
.mutation(({ input }) => {
const existing = localDb
.select()
.from(workspaceSections)
.where(eq(workspaceSections.projectId, input.projectId))
.all();

const nextTabOrder = computeNextTabOrder(
existing.map((s) => s.tabOrder),
);
const nextTabOrder = getMaxProjectChildTabOrder(input.projectId) + 1;

const section = localDb
.insert(workspaceSections)
Expand Down
Loading
Loading