Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
87016bc
fix(web): manual project order can be changed again
HarunRRayhan Nov 9, 2026
e1d4710
fix(web): project ordering has its own switch and numbered rows
HarunRRayhan Nov 9, 2026
a92211f
fix(web): boring sidebar shows project order and a drag locks it
HarunRRayhan Nov 9, 2026
e99110b
fix(web): customize project order from Labs, pin from the list
HarunRRayhan Nov 9, 2026
ea112db
fix(web): project list keeps its arrow last, after pin
HarunRRayhan Nov 9, 2026
7c6ea4c
fix(web): dragging onto a grouped project keeps its checkouts together
HarunRRayhan Nov 9, 2026
31deb1d
fix(web): project reorder announcements read project names
HarunRRayhan Nov 9, 2026
d4d9859
fix(web): keep the project order list to ordering
HarunRRayhan Nov 9, 2026
3da2625
test(web): cover reordering below the pinned divider
HarunRRayhan Nov 9, 2026
9fbbd6c
Merge remote-tracking branch 'origin/main' into fix/web-manual-projec…
HarunRRayhan Nov 9, 2026
63b09ee
fix(web): announce when a drop across the pinned line is ignored
HarunRRayhan Nov 9, 2026
e113a04
fix(web): allow keyboard project reordering in settings
maria-rcks Nov 10, 2026
bf7173d
Merge remote-tracking branch 'origin/main' into fix/web-manual-projec…
juliusmarminge Nov 12, 2026
e4ff19e
fix(web): edit manual project order from the existing Project order s…
juliusmarminge Nov 12, 2026
9945e81
Merge remote-tracking branch 'origin/main' into fix/web-manual-projec…
juliusmarminge Nov 12, 2026
644d413
fix(web): project order intro is not a live region
juliusmarminge Nov 12, 2026
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
10 changes: 10 additions & 0 deletions apps/web/src/components/AppRouteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ export function AppRouteModal({ label, children }: { label: string; children: Re
aria-label={label}
data-slot="dialog-popup"
data-app-modal=""
onKeyDown={(event) => {
if (
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
event.target instanceof HTMLElement &&
event.target.closest('[aria-roledescription="sortable"][aria-pressed="true"]')
) {
// dnd-kit's keyboard sensor listens on document, beyond the dialog's key guard.
event.preventBaseUIHandler();
}
}}
className="relative flex size-full min-h-0 min-w-0 flex-col overflow-hidden bg-background max-sm:pt-(--app-modal-top-inset) max-sm:pb-safe text-foreground outline-none transition-[opacity,scale] duration-150 ease-out [-webkit-app-region:no-drag] data-ending-style:scale-[0.985] data-ending-style:opacity-0 data-starting-style:scale-[0.985] data-starting-style:opacity-0 sm:max-h-[60rem] sm:max-w-[80rem] sm:rounded-2xl sm:border sm:shadow-2xl/40"
>
{/* The emptied strip still moves the window, as the titlebar it replaces does. */}
Expand Down
76 changes: 76 additions & 0 deletions apps/web/src/components/settings/ProjectOrderList.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";

import { render } from "../../../test/render";
import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
import { useUiStateStore } from "../../uiStateStore";
import { AppRouteModal } from "../AppRouteModal";
import { ProjectOrderList } from "./ProjectOrderList";

const state = vi.hoisted(() => ({
groups: [] as SidebarProjectSnapshot[],
}));

vi.mock(import("../../hooks/useSidebarProjectGroups"), () => ({
useSidebarProjectGroups: () => state.groups,
}));
vi.mock(import("../../state/entities"), async (original) => ({
...(await original()),
useProjects: () => [],
}));
vi.mock(import("./SettingsScopeContext"), async (original) => ({
...(await original()),
useSettingsScope: () => ({ selectScope: vi.fn() }) as never,
}));
vi.mock(import("../ProjectFavicon"), () => ({ ProjectFavicon: () => <span /> }));

function group(name: string, memberKeys: readonly string[], pinned = false) {
return {
projectKey: name,
displayName: name,
workspaceRoot: `/code/${name}`,
pinned,
archived: false,
memberProjects: memberKeys.map((physicalProjectKey) => ({ physicalProjectKey })),
} as unknown as SidebarProjectSnapshot;
}

function setup(groups: SidebarProjectSnapshot[]) {
state.groups = groups;
useUiStateStore.setState({ projectOrder: [] });
}

async function moveDown(name: string) {
const handle = page.getByRole("button", { name: new RegExp(`^Reorder ${name},`) });
await expect.element(handle).toBeVisible();
handle.element().focus();
await userEvent.keyboard(" ");
await expect.element(handle).toHaveAttribute("aria-pressed", "true");
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard(" ");
await expect.element(handle).not.toHaveAttribute("aria-pressed", "true");
}

describe("ProjectOrderList", () => {
it("saves a dragged project's checkouts together", async () => {
setup([group("alpha", ["a-local", "a-remote"]), group("beta", ["b"]), group("gamma", ["g"])]);
await using _screen = await render(
<AppRouteModal label="Settings">
<ProjectOrderList />
</AppRouteModal>,
);

await moveDown("alpha");

expect(useUiStateStore.getState().projectOrder).toEqual(["b", "a-local", "a-remote", "g"]);
});

it("leaves the order alone when a drop crosses the pinned line", async () => {
setup([group("pinned", ["p"], true), group("beta", ["b"])]);
await using _screen = await render(<ProjectOrderList />);

await moveDown("pinned");

expect(useUiStateStore.getState().projectOrder).toEqual([]);
});
});
199 changes: 199 additions & 0 deletions apps/web/src/components/settings/ProjectOrderList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import {
type Announcements,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
type UniqueIdentifier,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
pickableProjectGroups,
selectProjectGroupingSettings,
} from "@t3tools/client-runtime/state/project-grouping";
import { ChevronRightIcon, GripVerticalIcon, PinIcon } from "lucide-react";
import { Fragment } from "react";

import { useClientSettings } from "../../hooks/useSettings";
import { useSidebarProjectGroups } from "../../hooks/useSidebarProjectGroups";
import { cn } from "../../lib/utils";
import {
getSidebarProjectSettingsKey,
type SidebarProjectSnapshot,
} from "../../sidebarProjectGrouping";
import { useProjects } from "../../state/entities";
import { useUiStateStore } from "../../uiStateStore";
import { ProjectFavicon } from "../ProjectFavicon";
import { ITEM_ROW_CLASSNAME } from "./itemRows";
import { SettingsGroup } from "./SettingsGroup";
import { useSettingsScope } from "./SettingsScopeContext";

const memberKeys = (group: SidebarProjectSnapshot) =>
group.memberProjects.map((member) => member.physicalProjectKey);

export function ProjectOrderList() {
const groups = useSidebarProjectGroups();
const projects = useProjects();
const groupingSettings = useClientSettings(selectProjectGroupingSettings);
const reorderProjects = useUiStateStore((store) => store.reorderProjects);
const { selectScope } = useSettingsScope();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const shown = pickableProjectGroups(groups);

// dnd-kit announces raw ids by default, and these ids are internal project keys.
const findGroup = (id: UniqueIdentifier | undefined) =>
shown.find((group) => group.projectKey === id);
const nameOf = (id: UniqueIdentifier) => findGroup(id)?.displayName ?? String(id);
const announcements: Announcements = {
onDragStart: ({ active }) => `Picked up ${nameOf(active.id)}.`,
onDragOver: ({ active, over }) =>
over ? `${nameOf(active.id)} is over ${nameOf(over.id)}.` : undefined,
onDragEnd: ({ active, over }) => {
if (!over) return `${nameOf(active.id)} was dropped.`;
return findGroup(active.id)?.pinned === findGroup(over.id)?.pinned
? `${nameOf(active.id)} was dropped on ${nameOf(over.id)}.`
: `${nameOf(active.id)} did not move. Pinned projects stay above unpinned projects.`;
},
onDragCancel: ({ active }) => `Moving ${nameOf(active.id)} was cancelled.`,
};

const handleDragEnd = ({ active, over }: DragEndEvent) => {
const dragged = findGroup(active.id);
const target = findGroup(over?.id);
// Pinned projects always sort first, so a drop across that line could not stick.
if (!dragged || !target || dragged === target || dragged.pinned !== target.pinned) return;
reorderProjects(groups.flatMap(memberKeys), memberKeys(dragged), memberKeys(target));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};

return (
<div className="space-y-3">
<p className="px-3 text-sm text-muted-foreground sm:px-4">
Choose a project to manage its name, icon, checkouts and actions. Drag projects to fix their
order in the sidebar.
</p>
<SettingsGroup>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
accessibility={{ announcements }}
onDragEnd={handleDragEnd}
>
<SortableContext
items={shown.map((group) => group.projectKey)}
strategy={verticalListSortingStrategy}
>
<ol aria-label="Projects, in sidebar order">
{shown.map((group, index) => (
<Fragment key={group.projectKey}>
{shown[index - 1]?.pinned && !group.pinned ? (
<li aria-hidden className="mx-3 border-t border-border sm:mx-4" />
) : null}
<SortableProjectRow
group={group}
position={index + 1}
onOpen={() =>
selectScope({
project: getSidebarProjectSettingsKey(group, groupingSettings, projects),
})
}
/>
</Fragment>
))}
</ol>
</SortableContext>
</DndContext>
</SettingsGroup>
</div>
);
}

function SortableProjectRow({
group,
position,
onOpen,
}: {
readonly group: SidebarProjectSnapshot;
readonly position: number;
readonly onOpen: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: group.projectKey });

return (
<li
ref={setNodeRef}
style={{ transform: CSS.Translate.toString(transform), transition }}
className={cn(
ITEM_ROW_CLASSNAME,
"flex items-center gap-2",
isDragging && "relative z-10 rounded-md bg-background shadow-md",
)}
>
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
aria-label={`Reorder ${group.displayName}, position ${position}`}
className="flex size-6 shrink-0 cursor-grab touch-none items-center justify-center rounded text-muted-foreground/70 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring active:cursor-grabbing"
>
<GripVerticalIcon className="size-3.5" />
</button>
<span
aria-hidden
className="w-5 shrink-0 text-right text-xs text-muted-foreground tabular-nums"
>
{position}
</span>
<button
type="button"
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onClick={onOpen}
>
<ProjectFavicon project={group} className="size-4 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{group.displayName}</span>
<span className="block truncate text-xs text-muted-foreground">
{group.workspaceRoot}
</span>
</span>
</button>
{group.pinned ? (
<span className="flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
<PinIcon aria-hidden className="size-3" />
Pinned
</span>
) : null}
<button
type="button"
tabIndex={-1}
aria-hidden
className="flex shrink-0 cursor-pointer items-center text-muted-foreground"
onClick={onOpen}
>
<ChevronRightIcon className="size-4" />
</button>
</li>
);
}
9 changes: 9 additions & 0 deletions apps/web/src/components/settings/ProjectsSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { EnvironmentId } from "@t3tools/contracts";
import { LayersIcon } from "lucide-react";

import { useClientSettings } from "../../hooks/useSettings";
import { Button } from "../ui/button";
import { ProjectOrderList } from "./ProjectOrderList";
import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
import { useSettingsScope } from "./SettingsScopeContext";
import { SettingsScopeNotice } from "./SettingsScopeNotice";
Expand All @@ -10,6 +12,9 @@ import { ALL_ENVIRONMENTS_VALUE, selectEnvironmentAxis } from "./settingsScopeAx

export function ProjectsSettings() {
const { search: value, scope, selectScope } = useSettingsScope();
const manualOrder = useClientSettings(
(settings) => settings.sidebarProjectSortOrder === "manual",
);
const unavailable =
scope.kind === "unavailable" ? (
<SettingsPageContainer>
Expand Down Expand Up @@ -45,6 +50,10 @@ export function ProjectsSettings() {
/>
) : unavailable ? (
unavailable
) : manualOrder ? (
<SettingsPageContainer>
<ProjectOrderList />
</SettingsPageContainer>
) : (
<SettingsScopeNotice target="project">
Choose a project to manage its name, icon, checkouts and actions.
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,12 @@ export function GeneralSettingsPanel() {
/>
<SettingsRow
{...setting("project-order")}
description={<T>Order of projects in the sidebar project picker and command palette.</T>}
description={
<T>
Order of projects in the sidebar project picker and command palette. Set a manual
order by dragging projects in Settings → Projects.
</T>
}
resetAction={
settings.sidebarProjectSortOrder !==
DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder ? (
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/uiStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ describe("uiStateStore pure functions", () => {
const next = reorderProjects(makeUiState(), currentOrder, [keyALocal, keyARemote], [keyC]);

expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);
expect(
reorderProjects(
makeUiState(),
[keyB, keyALocal, keyARemote, keyC],
[keyB],
[keyALocal, keyARemote],
).projectOrder,
).toEqual([keyALocal, keyARemote, keyB, keyC]);
});
});

Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/uiStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,10 @@ export function reorderProjects(
return state;
}

const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1);
const insertIndex =
draggedBeforeTarget > 0
? projectOrder.findLastIndex((id) => targetSet.has(id)) + 1
: originalTargetIndex;
projectOrder.splice(insertIndex, 0, ...removed);
return {
...state,
Expand Down
4 changes: 3 additions & 1 deletion docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ cannot drag or move threads within it. Your saved order returns when you turn it
### Group threads by project (beta)

Turn on **Settings → Labs → Boring sidebar (group by project)** on web and desktop to put active
threads in a folder for each project. Pinned projects come first. A project with no active
threads in a folder for each project. Pinned projects come first. To fix the folder order, set
**Settings → General → Project order** to **Manual** and drag projects into place in **Settings →
Projects**; agent activity won't move them then. A project with no active
threads has no folder unless it is pinned or you pick it in the project filter. An archived
project's folder comes last and shows only while it has active threads. Click a folder to open or
close it. A closed folder shows how many threads it holds, and a dot when one of them
Expand Down
Loading