History

Add sidebar project reordering

Cursor Agent committed 8 months ago

Commit a8a5f7c1 parent d5c97fa3 files · +413 −245Browse files

Unified · Split

3 files
  1. apps/web/src/components/Sidebar.tsx +289 −228
  2. apps/web/src/store.test.ts +72 −14
  3. apps/web/src/store.ts +52 −3
apps/web/src/components/Sidebar.tsx+289−228
@@ -2,11 +2,12 @@ import { · 1 unmodified line
22 ChevronRightIcon,
33 FolderIcon,
44 GitPullRequestIcon,
5+ GripVerticalIcon,
56 RocketIcon,
67 SquarePenIcon,
78 TerminalIcon,
89 } from "lucide-react";
9−import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10+import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
1011 import {
1112 DEFAULT_MODEL,
1213 type DesktopUpdateState,
@@ -261,6 +262,7 @@ export default function Sidebar() { · 248 unmodified lines
261262 const threads = useStore((store) => store.threads);
262263 const markThreadUnread = useStore((store) => store.markThreadUnread);
263264 const toggleProject = useStore((store) => store.toggleProject);
265+ const reorderProjects = useStore((store) => store.reorderProjects);
264266 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
265267 const getDraftThreadByProjectId = useComposerDraftStore(
266268 (store) => store.getDraftThreadByProjectId,
@@ -297,6 +299,8 @@ export default function Sidebar() { · 30 unmodified lines
297299 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
298300 ReadonlySet<ProjectId>
299301 >(() => new Set());
302+ const [draggedProjectId, setDraggedProjectId] = useState<ProjectId | null>(null);
303+ const [projectDropIndex, setProjectDropIndex] = useState<number | null>(null);
300304 const renamingCommittedRef = useRef(false);
301305 const renamingInputRef = useRef<HTMLInputElement | null>(null);
302306 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
@@ -982,6 +986,11 @@ export default function Sidebar() { · 679 unmodified lines
982986 </div>
983987 );
984988
989+ const resetProjectDrag = () => {
990+ setDraggedProjectId(null);
991+ setProjectDropIndex(null);
992+ };
993+
985994 return (
986995 <>
987996 {isElectron ? (
@@ -1018,7 +1027,7 @@ export default function Sidebar() { · 30 unmodified lines
10181027 <SidebarContent className="gap-0">
10191028 <SidebarGroup className="px-2 py-2">
10201029 <SidebarMenu>
1021− {projects.map((project) => {
1030+ {projects.map((project, projectIndex) => {
10221031 const projectThreads = threads
10231032 .filter((thread) => thread.projectId === project.id)
10241033 .toSorted((a, b) => {
@@ -1034,246 +1043,298 @@ export default function Sidebar() { · 9 unmodified lines
10341043 : projectThreads;
10351044
10361045 return (
1037− <Collapsible
1038− key={project.id}
1039− className="group/collapsible"
1040− open={project.expanded}
1041− onOpenChange={(open) => {
1042− if (open === project.expanded) return;
1043− toggleProject(project.id);
1044− }}
1045− >
1046− <SidebarMenuItem>
1047− <div className="group/project-header relative">
1048− <CollapsibleTrigger
1049− render={
1050− <SidebarMenuButton
1051− size="sm"
1052− className="gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground"
1053− />
1054− }
1055− onContextMenu={(event) => {
1056− event.preventDefault();
1057− void handleProjectContextMenu(project.id, {
1058− x: event.clientX,
1059− y: event.clientY,
1060− });
1061− }}
1062− >
1063− <ChevronRightIcon
1064− className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1065− project.expanded ? "rotate-90" : ""
1066− }`}
1067− />
1068− <ProjectFavicon cwd={project.cwd} />
1069− <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1070− {project.name}
1071− </span>
1072− </CollapsibleTrigger>
1073− <Tooltip>
1074− <TooltipTrigger
1046+ <Fragment key={project.id}>
1047+ {draggedProjectId !== null && projectDropIndex === projectIndex && (
1048+ <SidebarMenuItem aria-hidden className="pointer-events-none px-2 py-0.5">
1049+ <div className="h-0.5 w-full rounded-full bg-primary/80" />
1050+ </SidebarMenuItem>
1051+ )}
1052+ <Collapsible
1053+ className="group/collapsible"
1054+ open={project.expanded}
1055+ onOpenChange={(open) => {
1056+ if (open === project.expanded) return;
1057+ toggleProject(project.id);
1058+ }}
1059+ >
1060+ <SidebarMenuItem className={draggedProjectId === project.id ? "opacity-60" : ""}>
1061+ <div className="group/project-header relative">
1062+ <CollapsibleTrigger
10751063 render={
1076− <SidebarMenuAction
1077− render={
1078− <button
1079− type="button"
1080− aria-label={`Create new thread in ${project.name}`}
1081− />
1082− }
1083− showOnHover
1084− className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1085− onClick={(event) => {
1064+ <SidebarMenuButton
1065+ size="sm"
1066+ draggable
1067+ aria-grabbed={draggedProjectId === project.id}
1068+ className="gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground cursor-grab active:cursor-grabbing"
1069+ onDragStart={(event) => {
1070+ setDraggedProjectId(project.id);
1071+ setProjectDropIndex(null);
1072+ event.dataTransfer.effectAllowed = "move";
1073+ event.dataTransfer.setData("text/plain", project.cwd);
1074+ }}
1075+ onDragEnd={() => {
1076+ resetProjectDrag();
1077+ }}
1078+ onDragOver={(event) => {
1079+ if (draggedProjectId === null) return;
10861080 event.preventDefault();
1087− event.stopPropagation();
1088− void handleNewThread(project.id);
1081+ event.dataTransfer.dropEffect = "move";
1082+ const bounds = event.currentTarget.getBoundingClientRect();
1083+ const nextDropIndex =
1084+ event.clientY < bounds.top + bounds.height / 2
1085+ ? projectIndex
1086+ : projectIndex + 1;
1087+ if (projectDropIndex !== nextDropIndex) {
1088+ setProjectDropIndex(nextDropIndex);
1089+ }
10891090 }}
1090− >
1091− <SquarePenIcon className="size-3.5" />
1092− </SidebarMenuAction>
1091+ onDrop={(event) => {
1092+ if (draggedProjectId === null) return;
1093+ event.preventDefault();
1094+ const bounds = event.currentTarget.getBoundingClientRect();
1095+ const destinationIndex =
1096+ event.clientY < bounds.top + bounds.height / 2
1097+ ? projectIndex
1098+ : projectIndex + 1;
1099+ reorderProjects(draggedProjectId, destinationIndex);
1100+ resetProjectDrag();
1101+ }}
1102+ />
10931103 }
1094− />
1095− <TooltipPopup side="top">
1096− {newThreadShortcutLabel
1097− ? `New thread (${newThreadShortcutLabel})`
1098− : "New thread"}
1099− </TooltipPopup>
1100− </Tooltip>
1101− </div>
1102−
1103− <CollapsibleContent>
1104− <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
1105− {visibleThreads.map((thread) => {
1106− const isActive = routeThreadId === thread.id;
1107− const threadStatus = threadStatusPill(
1108− thread,
1109− pendingApprovalByThreadId.get(thread.id) === true,
1110− );
1111− const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
1112− const terminalStatus = terminalStatusFromRunningIds(
1113− selectThreadTerminalState(terminalStateByThreadId, thread.id)
1114− .runningTerminalIds,
1115− );
1116−
1117− return (
1118− <SidebarMenuSubItem key={thread.id} className="w-full">
1119− <SidebarMenuSubButton
1120− render={<div role="button" tabIndex={0} />}
1121− size="sm"
1122− isActive={isActive}
1123− className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left hover:bg-accent hover:text-foreground ${
1124− isActive
1125− ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50"
1126− : "text-muted-foreground"
1127− }`}
1128− onClick={() => {
1129− void navigate({
1130− to: "/$threadId",
1131− params: { threadId: thread.id },
1132− });
1133− }}
1134− onKeyDown={(event) => {
1135− if (event.key !== "Enter" && event.key !== " ") return;
1136− event.preventDefault();
1137− void navigate({
1138− to: "/$threadId",
1139− params: { threadId: thread.id },
1140− });
1141− }}
1142− onContextMenu={(event) => {
1104+ onContextMenu={(event) => {
1105+ event.preventDefault();
1106+ void handleProjectContextMenu(project.id, {
1107+ x: event.clientX,
1108+ y: event.clientY,
1109+ });
1110+ }}
1111+ >
1112+ <GripVerticalIcon
1113+ aria-hidden
1114+ className="size-3 shrink-0 text-muted-foreground/35"
1115+ />
1116+ <ChevronRightIcon
1117+ className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1118+ project.expanded ? "rotate-90" : ""
1119+ }`}
1120+ />
1121+ <ProjectFavicon cwd={project.cwd} />
1122+ <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1123+ {project.name}
1124+ </span>
1125+ </CollapsibleTrigger>
1126+ <Tooltip>
1127+ <TooltipTrigger
1128+ render={
1129+ <SidebarMenuAction
1130+ render={
1131+ <button
1132+ type="button"
1133+ aria-label={`Create new thread in ${project.name}`}
1134+ />
1135+ }
1136+ showOnHover
1137+ className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1138+ onClick={(event) => {
11431139 event.preventDefault();
1144− void handleThreadContextMenu(thread.id, {
1145− x: event.clientX,
1146− y: event.clientY,
1147− });
1140+ event.stopPropagation();
1141+ void handleNewThread(project.id);
11481142 }}
11491143 >
1150− <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
1151− {prStatus && (
1152− <Tooltip>
1153− <TooltipTrigger
1154− render={
1155− <button
1156− type="button"
1157− aria-label={prStatus.tooltip}
1158− className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
1159− onClick={(event) => {
1160− openPrLink(event, prStatus.url);
1161− }}
1162− >
1163− <GitPullRequestIcon className="size-3" />
1164− </button>
1165− }
1166− />
1167− <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
1168− </Tooltip>
1169− )}
1170− {threadStatus && (
1171− <span
1172− className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
1173− >
1144+ <SquarePenIcon className="size-3.5" />
1145+ </SidebarMenuAction>
1146+ }
1147+ />
1148+ <TooltipPopup side="top">
1149+ {newThreadShortcutLabel
1150+ ? `New thread (${newThreadShortcutLabel})`
1151+ : "New thread"}
1152+ </TooltipPopup>
1153+ </Tooltip>
1154+ </div>
1155+
1156+ <CollapsibleContent>
1157+ <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
1158+ {visibleThreads.map((thread) => {
1159+ const isActive = routeThreadId === thread.id;
1160+ const threadStatus = threadStatusPill(
1161+ thread,
1162+ pendingApprovalByThreadId.get(thread.id) === true,
1163+ );
1164+ const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
1165+ const terminalStatus = terminalStatusFromRunningIds(
1166+ selectThreadTerminalState(terminalStateByThreadId, thread.id)
1167+ .runningTerminalIds,
1168+ );
1169+
1170+ return (
1171+ <SidebarMenuSubItem key={thread.id} className="w-full">
1172+ <SidebarMenuSubButton
1173+ render={<div role="button" tabIndex={0} />}
1174+ size="sm"
1175+ isActive={isActive}
1176+ className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left hover:bg-accent hover:text-foreground ${
1177+ isActive
1178+ ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50"
1179+ : "text-muted-foreground"
1180+ }`}
1181+ onClick={() => {
1182+ void navigate({
1183+ to: "/$threadId",
1184+ params: { threadId: thread.id },
1185+ });
1186+ }}
1187+ onKeyDown={(event) => {
1188+ if (event.key !== "Enter" && event.key !== " ") return;
1189+ event.preventDefault();
1190+ void navigate({
1191+ to: "/$threadId",
1192+ params: { threadId: thread.id },
1193+ });
1194+ }}
1195+ onContextMenu={(event) => {
1196+ event.preventDefault();
1197+ void handleThreadContextMenu(thread.id, {
1198+ x: event.clientX,
1199+ y: event.clientY,
1200+ });
1201+ }}
1202+ >
1203+ <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
1204+ {prStatus && (
1205+ <Tooltip>
1206+ <TooltipTrigger
1207+ render={
1208+ <button
1209+ type="button"
1210+ aria-label={prStatus.tooltip}
1211+ className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
1212+ onClick={(event) => {
1213+ openPrLink(event, prStatus.url);
1214+ }}
1215+ >
1216+ <GitPullRequestIcon className="size-3" />
1217+ </button>
1218+ }
1219+ />
1220+ <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
1221+ </Tooltip>
1222+ )}
1223+ {threadStatus && (
11741224 <span
1175− className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${
1176− threadStatus.pulse ? "animate-pulse" : ""
1177− }`}
1225+ className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
1226+ >
1227+ <span
1228+ className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${
1229+ threadStatus.pulse ? "animate-pulse" : ""
1230+ }`}
1231+ />
1232+ <span className="hidden md:inline">{threadStatus.label}</span>
1233+ </span>
1234+ )}
1235+ {renamingThreadId === thread.id ? (
1236+ <input
1237+ ref={(el) => {
1238+ if (el && renamingInputRef.current !== el) {
1239+ renamingInputRef.current = el;
1240+ el.focus();
1241+ el.select();
1242+ }
1243+ }}
1244+ className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
1245+ value={renamingTitle}
1246+ onChange={(e) => setRenamingTitle(e.target.value)}
1247+ onKeyDown={(e) => {
1248+ e.stopPropagation();
1249+ if (e.key === "Enter") {
1250+ e.preventDefault();
1251+ renamingCommittedRef.current = true;
1252+ void commitRename(thread.id, renamingTitle, thread.title);
1253+ } else if (e.key === "Escape") {
1254+ e.preventDefault();
1255+ renamingCommittedRef.current = true;
1256+ cancelRename();
1257+ }
1258+ }}
1259+ onBlur={() => {
1260+ if (!renamingCommittedRef.current) {
1261+ void commitRename(thread.id, renamingTitle, thread.title);
1262+ }
1263+ }}
1264+ onClick={(e) => e.stopPropagation()}
11781265 />
1179− <span className="hidden md:inline">{threadStatus.label}</span>
1180− </span>
1181− )}
1182− {renamingThreadId === thread.id ? (
1183− <input
1184− ref={(el) => {
1185− if (el && renamingInputRef.current !== el) {
1186− renamingInputRef.current = el;
1187− el.focus();
1188− el.select();
1189− }
1190− }}
1191− className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
1192− value={renamingTitle}
1193− onChange={(e) => setRenamingTitle(e.target.value)}
1194− onKeyDown={(e) => {
1195− e.stopPropagation();
1196− if (e.key === "Enter") {
1197− e.preventDefault();
1198− renamingCommittedRef.current = true;
1199− void commitRename(thread.id, renamingTitle, thread.title);
1200− } else if (e.key === "Escape") {
1201− e.preventDefault();
1202− renamingCommittedRef.current = true;
1203− cancelRename();
1204− }
1205− }}
1206− onBlur={() => {
1207− if (!renamingCommittedRef.current) {
1208− void commitRename(thread.id, renamingTitle, thread.title);
1209− }
1210− }}
1211− onClick={(e) => e.stopPropagation()}
1212− />
1213− ) : (
1214− <span className="min-w-0 flex-1 truncate text-xs">
1215− {thread.title}
1216− </span>
1217− )}
1218− </div>
1219− <div className="ml-auto flex shrink-0 items-center gap-1.5">
1220− {terminalStatus && (
1266+ ) : (
1267+ <span className="min-w-0 flex-1 truncate text-xs">
1268+ {thread.title}
1269+ </span>
1270+ )}
1271+ </div>
1272+ <div className="ml-auto flex shrink-0 items-center gap-1.5">
1273+ {terminalStatus && (
1274+ <span
1275+ role="img"
1276+ aria-label={terminalStatus.label}
1277+ title={terminalStatus.label}
1278+ className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
1279+ >
1280+ <TerminalIcon
1281+ className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1282+ />
1283+ </span>
1284+ )}
12211285 <span
1222− role="img"
1223− aria-label={terminalStatus.label}
1224− title={terminalStatus.label}
1225− className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
1286+ className={`text-[10px] ${
1287+ isActive ? "text-foreground/65" : "text-muted-foreground/40"
1288+ }`}
12261289 >
1227− <TerminalIcon
1228− className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1229− />
1290+ {formatRelativeTime(thread.createdAt)}
12301291 </span>
1231− )}
1232− <span
1233− className={`text-[10px] ${
1234− isActive ? "text-foreground/65" : "text-muted-foreground/40"
1235− }`}
1236− >
1237− {formatRelativeTime(thread.createdAt)}
1238− </span>
1239− </div>
1292+ </div>
1293+ </SidebarMenuSubButton>
1294+ </SidebarMenuSubItem>
1295+ );
1296+ })}
1297+
1298+ {hasHiddenThreads && !isThreadListExpanded && (
1299+ <SidebarMenuSubItem className="w-full">
1300+ <SidebarMenuSubButton
1301+ render={<button type="button" />}
1302+ size="sm"
1303+ className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1304+ onClick={() => {
1305+ expandThreadListForProject(project.id);
1306+ }}
1307+ >
1308+ <span>Show more</span>
12401309 </SidebarMenuSubButton>
12411310 </SidebarMenuSubItem>
1242− );
1243− })}
1244−
1245− {hasHiddenThreads && !isThreadListExpanded && (
1246− <SidebarMenuSubItem className="w-full">
1247− <SidebarMenuSubButton
1248− render={<button type="button" />}
1249− size="sm"
1250− className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1251− onClick={() => {
1252− expandThreadListForProject(project.id);
1253− }}
1254− >
1255− <span>Show more</span>
1256− </SidebarMenuSubButton>
1257− </SidebarMenuSubItem>
1258− )}
1259− {hasHiddenThreads && isThreadListExpanded && (
1260− <SidebarMenuSubItem className="w-full">
1261− <SidebarMenuSubButton
1262− render={<button type="button" />}
1263− size="sm"
1264− className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1265− onClick={() => {
1266− collapseThreadListForProject(project.id);
1267− }}
1268− >
1269− <span>Show less</span>
1270− </SidebarMenuSubButton>
1271− </SidebarMenuSubItem>
1272− )}
1273− </SidebarMenuSub>
1274− </CollapsibleContent>
1275− </SidebarMenuItem>
1276− </Collapsible>
1311+ )}
1312+ {hasHiddenThreads && isThreadListExpanded && (
1313+ <SidebarMenuSubItem className="w-full">
1314+ <SidebarMenuSubButton
1315+ render={<button type="button" />}
1316+ size="sm"
1317+ className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1318+ onClick={() => {
1319+ collapseThreadListForProject(project.id);
1320+ }}
1321+ >
1322+ <span>Show less</span>
1323+ </SidebarMenuSubButton>
1324+ </SidebarMenuSubItem>
1325+ )}
1326+ </SidebarMenuSub>
1327+ </CollapsibleContent>
1328+ </SidebarMenuItem>
1329+ </Collapsible>
1330+ {draggedProjectId !== null &&
1331+ projectDropIndex === projectIndex + 1 &&
1332+ projectIndex === projects.length - 1 && (
1333+ <SidebarMenuItem aria-hidden className="pointer-events-none px-2 py-0.5">
1334+ <div className="h-0.5 w-full rounded-full bg-primary/80" />
1335+ </SidebarMenuItem>
1336+ )}
1337+ </Fragment>
12771338 );
12781339 })}
12791340 </SidebarMenu>
apps/web/src/store.test.ts+72−14
@@ -1,8 +1,19 @@
1−import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
1+import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3tools/contracts";
22 import { describe, expect, it } from "vitest";
33
4−import { markThreadUnread, type AppState } from "./store";
5−import type { Thread } from "./types";
4+import { markThreadUnread, reorderProjects, syncServerReadModel, type AppState } from "./store";
5+import type { Project, Thread } from "./types";
6+
7+function makeProject(id: string, cwd = `/tmp/${id}`): Project {
8+ return {
9+ id: ProjectId.makeUnsafe(id),
10+ name: id,
11+ cwd,
12+ model: "gpt-5-codex",
13+ expanded: true,
14+ scripts: [],
15+ };
16+}
617
718 function makeThread(overrides: Partial<Thread> = {}): Thread {
819 return {
@@ -24,25 +35,72 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 15 unmodified lines
2435 };
2536 }
2637
27−function makeState(thread: Thread): AppState {
38+function makeState(thread: Thread, projects: Project[] = [makeProject("project-1")]): AppState {
2839 return {
29− projects: [
30− {
31− id: ProjectId.makeUnsafe("project-1"),
32− name: "Project",
33− cwd: "/tmp/project",
34− model: "gpt-5-codex",
35− expanded: true,
36− scripts: [],
37− },
38− ],
40+ projects,
3941 threads: [thread],
4042 threadsHydrated: true,
4143 runtimeMode: "full-access",
4244 };
4345 }
4446
47+function makeReadModel(projects: Array<{ id: string; cwd?: string }>): OrchestrationReadModel {
48+ return {
49+ snapshotSequence: 1,
50+ projects: projects.map((project) => ({
51+ id: ProjectId.makeUnsafe(project.id),
52+ title: project.id,
53+ workspaceRoot: project.cwd ?? `/tmp/${project.id}`,
54+ defaultModel: "gpt-5-codex",
55+ scripts: [],
56+ createdAt: "2026-02-25T12:00:00.000Z",
57+ updatedAt: "2026-02-25T12:00:00.000Z",
58+ deletedAt: null,
59+ })),
60+ threads: [],
61+ updatedAt: "2026-02-25T12:00:00.000Z",
62+ };
63+}
64+
4565 describe("store pure functions", () => {
66+ it("reorderProjects moves a project to the end insertion slot", () => {
67+ const initialState = makeState(makeThread(), [
68+ makeProject("project-1"),
69+ makeProject("project-2"),
70+ makeProject("project-3"),
71+ ]);
72+
73+ const next = reorderProjects(initialState, ProjectId.makeUnsafe("project-1"), 3);
74+
75+ expect(next.projects.map((project) => project.id)).toEqual([
76+ ProjectId.makeUnsafe("project-2"),
77+ ProjectId.makeUnsafe("project-3"),
78+ ProjectId.makeUnsafe("project-1"),
79+ ]);
80+ });
81+
82+ it("syncServerReadModel keeps the existing local project order and appends new projects", () => {
83+ const initialState = makeState(makeThread(), [
84+ makeProject("project-b", "/tmp/project-b"),
85+ makeProject("project-a", "/tmp/project-a"),
86+ ]);
87+
88+ const next = syncServerReadModel(
89+ initialState,
90+ makeReadModel([
91+ { id: "project-a", cwd: "/tmp/project-a" },
92+ { id: "project-b", cwd: "/tmp/project-b" },
93+ { id: "project-c", cwd: "/tmp/project-c" },
94+ ]),
95+ );
96+
97+ expect(next.projects.map((project) => project.cwd)).toEqual([
98+ "/tmp/project-b",
99+ "/tmp/project-a",
100+ "/tmp/project-c",
101+ ]);
102+ });
103+
46104 it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {
47105 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
48106 const initialState = makeState(
apps/web/src/store.ts+52−3
@@ -44,24 +44,32 @@ const initialState: AppState = { · 43 unmodified lines
4444 runtimeMode: DEFAULT_RUNTIME_MODE,
4545 };
4646 const persistedExpandedProjectCwds = new Set<string>();
47+const persistedProjectOrderCwds: string[] = [];
4748
4849 // ── Persist helpers ──────────────────────────────────────────────────
4950
5051 function readPersistedState(): AppState {
5152 if (typeof window === "undefined") return initialState;
5253 try {
54+ persistedExpandedProjectCwds.clear();
55+ persistedProjectOrderCwds.length = 0;
5356 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
5457 if (!raw) return initialState;
5558 const parsed = JSON.parse(raw) as {
5659 runtimeMode?: RuntimeMode;
5760 expandedProjectCwds?: string[];
61+ projectOrderCwds?: string[];
5862 };
59− persistedExpandedProjectCwds.clear();
6063 for (const cwd of parsed.expandedProjectCwds ?? []) {
6164 if (typeof cwd === "string" && cwd.length > 0) {
6265 persistedExpandedProjectCwds.add(cwd);
6366 }
6467 }
68+ for (const cwd of parsed.projectOrderCwds ?? []) {
69+ if (typeof cwd === "string" && cwd.length > 0) {
70+ persistedProjectOrderCwds.push(cwd);
71+ }
72+ }
6573 return {
6674 ...initialState,
6775 runtimeMode:
@@ -84,6 +92,7 @@ function persistState(state: AppState): void { · 16 unmodified lines
8492 expandedProjectCwds: state.projects
8593 .filter((project) => project.expanded)
8694 .map((project) => project.cwd),
95+ projectOrderCwds: state.projects.map((project) => project.cwd),
8796 }),
8897 );
8998 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
@@ -115,7 +124,7 @@ function mapProjectsFromReadModel( · 25 unmodified lines
115124 incoming: OrchestrationReadModel["projects"],
116125 previous: Project[],
117126 ): Project[] {
118− return incoming.map((project) => {
127+ const mappedProjects = incoming.map((project) => {
119128 const existing =
120129 previous.find((entry) => entry.id === project.id) ??
121130 previous.find((entry) => entry.cwd === project.workspaceRoot);
@@ -132,6 +141,25 @@ function mapProjectsFromReadModel( · 10 unmodified lines
132141 scripts: project.scripts.map((script) => ({ ...script })),
133142 };
134143 });
144+ const preferredProjectOrderCwds =
145+ previous.length > 0 ? previous.map((project) => project.cwd) : persistedProjectOrderCwds;
146+ if (preferredProjectOrderCwds.length === 0 || mappedProjects.length <= 1) {
147+ return mappedProjects;
148+ }
149+ const orderByCwd = new Map(
150+ preferredProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
151+ );
152+ const orderedProjects = mappedProjects.toSorted((left, right) => {
153+ const leftIndex = orderByCwd.get(left.cwd);
154+ const rightIndex = orderByCwd.get(right.cwd);
155+ if (leftIndex === undefined && rightIndex === undefined) return 0;
156+ if (leftIndex === undefined) return 1;
157+ if (rightIndex === undefined) return -1;
158+ return leftIndex - rightIndex;
159+ });
160+ return orderedProjects.every((project, index) => project === mappedProjects[index])
161+ ? mappedProjects
162+ : orderedProjects;
135163 }
136164
137165 function toLegacySessionStatus(
@@ -333,6 +361,24 @@ export function setProjectExpanded( · 195 unmodified lines
333361 return changed ? { ...state, projects } : state;
334362 }
335363
364+export function reorderProjects(
365+ state: AppState,
366+ sourceProjectId: Project["id"],
367+ destinationIndex: number,
368+): AppState {
369+ const sourceIndex = state.projects.findIndex((project) => project.id === sourceProjectId);
370+ if (sourceIndex === -1) return state;
371+ const boundedDestinationIndex = Math.max(0, Math.min(destinationIndex, state.projects.length));
372+ const normalizedDestinationIndex =
373+ sourceIndex < boundedDestinationIndex ? boundedDestinationIndex - 1 : boundedDestinationIndex;
374+ if (normalizedDestinationIndex === sourceIndex) return state;
375+ const projects = [...state.projects];
376+ const [movedProject] = projects.splice(sourceIndex, 1);
377+ if (!movedProject) return state;
378+ projects.splice(normalizedDestinationIndex, 0, movedProject);
379+ return { ...state, projects };
380+}
381+
336382 export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
337383 const threads = updateThread(state.threads, threadId, (t) => {
338384 if (t.error === error) return t;
@@ -373,6 +419,7 @@ interface AppStore extends AppState { · 34 unmodified lines
373419 markThreadUnread: (threadId: ThreadId) => void;
374420 toggleProject: (projectId: Project["id"]) => void;
375421 setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
422+ reorderProjects: (sourceProjectId: Project["id"], destinationIndex: number) => void;
376423 setError: (threadId: ThreadId, error: string | null) => void;
377424 setThreadBranch: (
378425 threadId: ThreadId,
@@ -394,6 +441,8 @@ export const useStore = create<AppStore>((set) => ({ · 15 unmodified lines
394441 set((state) => toggleProject(state, projectId)),
395442 setProjectExpanded: (projectId, expanded) =>
396443 set((state) => setProjectExpanded(state, projectId, expanded)),
444+ reorderProjects: (sourceProjectId, destinationIndex) =>
445+ set((state) => reorderProjects(state, sourceProjectId, destinationIndex)),
397446 setError: (threadId, error) =>
398447 set((state) => setError(state, threadId, error)),
399448 setThreadBranch: (threadId, branch, worktreePath) =>
@@ -402,7 +451,7 @@ export const useStore = create<AppStore>((set) => ({ · 2 unmodified lines
402451 set((state) => setRuntimeMode(state, mode)),
403452 }));
404453
405−// Persist on every state change (only runtimeMode + expandedProjectCwds)
454+// Persist on every state change (runtimeMode + sidebar project UI state)
406455 useStore.subscribe((state) => persistState(state));
407456
408457 export function StoreProvider({ children }: { children: ReactNode }) {