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
2 ChevronRightIcon,2 ChevronRightIcon,
3 FolderIcon,3 FolderIcon,
4 GitPullRequestIcon,4 GitPullRequestIcon,
5 GripVerticalIcon,
5 RocketIcon,6 RocketIcon,
6 SquarePenIcon,7 SquarePenIcon,
7 TerminalIcon,8 TerminalIcon,
8} from "lucide-react";9} from "lucide-react";
9import { useCallback, useEffect, useMemo, useRef, useState } from "react";10import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
10import {11import {
11 DEFAULT_MODEL,12 DEFAULT_MODEL,
12 type DesktopUpdateState,13 type DesktopUpdateState,
@@ -261,6 +262,7 @@ export default function Sidebar() { · 248 unmodified lines
261 const threads = useStore((store) => store.threads);262 const threads = useStore((store) => store.threads);
262 const markThreadUnread = useStore((store) => store.markThreadUnread);263 const markThreadUnread = useStore((store) => store.markThreadUnread);
263 const toggleProject = useStore((store) => store.toggleProject);264 const toggleProject = useStore((store) => store.toggleProject);
265 const reorderProjects = useStore((store) => store.reorderProjects);
264 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);266 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
265 const getDraftThreadByProjectId = useComposerDraftStore(267 const getDraftThreadByProjectId = useComposerDraftStore(
266 (store) => store.getDraftThreadByProjectId,268 (store) => store.getDraftThreadByProjectId,
@@ -297,6 +299,8 @@ export default function Sidebar() { · 30 unmodified lines
297 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<299 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
298 ReadonlySet<ProjectId>300 ReadonlySet<ProjectId>
299 >(() => new Set());301 >(() => new Set());
302 const [draggedProjectId, setDraggedProjectId] = useState<ProjectId | null>(null);
303 const [projectDropIndex, setProjectDropIndex] = useState<number | null>(null);
300 const renamingCommittedRef = useRef(false);304 const renamingCommittedRef = useRef(false);
301 const renamingInputRef = useRef<HTMLInputElement | null>(null);305 const renamingInputRef = useRef<HTMLInputElement | null>(null);
302 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);306 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
@@ -982,6 +986,11 @@ export default function Sidebar() { · 679 unmodified lines
982 </div>986 </div>
983 );987 );
984 988
989 const resetProjectDrag = () => {
990 setDraggedProjectId(null);
991 setProjectDropIndex(null);
992 };
993
985 return (994 return (
986 <>995 <>
987 {isElectron ? (996 {isElectron ? (
@@ -1018,7 +1027,7 @@ export default function Sidebar() { · 30 unmodified lines
1018 <SidebarContent className="gap-0">1027 <SidebarContent className="gap-0">
1019 <SidebarGroup className="px-2 py-2">1028 <SidebarGroup className="px-2 py-2">
1020 <SidebarMenu>1029 <SidebarMenu>
1021 {projects.map((project) => {1030 {projects.map((project, projectIndex) => {
1022 const projectThreads = threads1031 const projectThreads = threads
1023 .filter((thread) => thread.projectId === project.id)1032 .filter((thread) => thread.projectId === project.id)
1024 .toSorted((a, b) => {1033 .toSorted((a, b) => {
@@ -1034,246 +1043,298 @@ export default function Sidebar() { · 9 unmodified lines
1034 : projectThreads;1043 : projectThreads;
1035 1044
1036 return (1045 return (
1037 <Collapsible1046 <Fragment key={project.id}>
1038 key={project.id}1047 {draggedProjectId !== null && projectDropIndex === projectIndex && (
1039 className="group/collapsible"1048 <SidebarMenuItem aria-hidden className="pointer-events-none px-2 py-0.5">
1040 open={project.expanded}1049 <div className="h-0.5 w-full rounded-full bg-primary/80" />
1041 onOpenChange={(open) => {1050 </SidebarMenuItem>
1042 if (open === project.expanded) return;1051 )}
1043 toggleProject(project.id);1052 <Collapsible
1044 }}1053 className="group/collapsible"
1045 >1054 open={project.expanded}
1046 <SidebarMenuItem>1055 onOpenChange={(open) => {
1047 <div className="group/project-header relative">1056 if (open === project.expanded) return;
1048 <CollapsibleTrigger1057 toggleProject(project.id);
1049 render={1058 }}
1050 <SidebarMenuButton1059 >
1051 size="sm"1060 <SidebarMenuItem className={draggedProjectId === project.id ? "opacity-60" : ""}>
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"1061 <div className="group/project-header relative">
1053 />1062 <CollapsibleTrigger
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
1075 render={1063 render={
1076 <SidebarMenuAction1064 <SidebarMenuButton
1077 render={1065 size="sm"
1078 <button1066 draggable
1079 type="button"1067 aria-grabbed={draggedProjectId === project.id}
1080 aria-label={`Create new thread in ${project.name}`}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"
1081 />1069 onDragStart={(event) => {
1082 }1070 setDraggedProjectId(project.id);
1083 showOnHover1071 setProjectDropIndex(null);
1084 className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"1072 event.dataTransfer.effectAllowed = "move";
1085 onClick={(event) => {1073 event.dataTransfer.setData("text/plain", project.cwd);
1074 }}
1075 onDragEnd={() => {
1076 resetProjectDrag();
1077 }}
1078 onDragOver={(event) => {
1079 if (draggedProjectId === null) return;
1086 event.preventDefault();1080 event.preventDefault();
1087 event.stopPropagation();1081 event.dataTransfer.dropEffect = "move";
1088 void handleNewThread(project.id);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 }
1089 }}1090 }}
1090 >1091 onDrop={(event) => {
1091 <SquarePenIcon className="size-3.5" />1092 if (draggedProjectId === null) return;
1092 </SidebarMenuAction>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 />
1093 }1103 }
1094 />1104 onContextMenu={(event) => {
1095 <TooltipPopup side="top">1105 event.preventDefault();
1096 {newThreadShortcutLabel1106 void handleProjectContextMenu(project.id, {
1097 ? `New thread (${newThreadShortcutLabel})`1107 x: event.clientX,
1098 : "New thread"}1108 y: event.clientY,
1099 </TooltipPopup>1109 });
1100 </Tooltip>1110 }}
1101 </div>1111 >
1102 1112 <GripVerticalIcon
1103 <CollapsibleContent>1113 aria-hidden
1104 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">1114 className="size-3 shrink-0 text-muted-foreground/35"
1105 {visibleThreads.map((thread) => {1115 />
1106 const isActive = routeThreadId === thread.id;1116 <ChevronRightIcon
1107 const threadStatus = threadStatusPill(1117 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1108 thread,1118 project.expanded ? "rotate-90" : ""
1109 pendingApprovalByThreadId.get(thread.id) === true,1119 }`}
1110 );1120 />
1111 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);1121 <ProjectFavicon cwd={project.cwd} />
1112 const terminalStatus = terminalStatusFromRunningIds(1122 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1113 selectThreadTerminalState(terminalStateByThreadId, thread.id)1123 {project.name}
1114 .runningTerminalIds,1124 </span>
1115 );1125 </CollapsibleTrigger>
1116 1126 <Tooltip>
1117 return (1127 <TooltipTrigger
1118 <SidebarMenuSubItem key={thread.id} className="w-full">1128 render={
1119 <SidebarMenuSubButton1129 <SidebarMenuAction
1120 render={<div role="button" tabIndex={0} />}1130 render={
1121 size="sm"1131 <button
1122 isActive={isActive}1132 type="button"
1123 className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left hover:bg-accent hover:text-foreground ${1133 aria-label={`Create new thread in ${project.name}`}
1124 isActive1134 />
1125 ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50"1135 }
1126 : "text-muted-foreground"1136 showOnHover
1127 }`}1137 className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1128 onClick={() => {1138 onClick={(event) => {
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) => {
1143 event.preventDefault();1139 event.preventDefault();
1144 void handleThreadContextMenu(thread.id, {1140 event.stopPropagation();
1145 x: event.clientX,1141 void handleNewThread(project.id);
1146 y: event.clientY,
1147 });
1148 }}1142 }}
1149 >1143 >
1150 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">1144 <SquarePenIcon className="size-3.5" />
1151 {prStatus && (1145 </SidebarMenuAction>
1152 <Tooltip>1146 }
1153 <TooltipTrigger1147 />
1154 render={1148 <TooltipPopup side="top">
1155 <button1149 {newThreadShortcutLabel
1156 type="button"1150 ? `New thread (${newThreadShortcutLabel})`
1157 aria-label={prStatus.tooltip}1151 : "New thread"}
1158 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}1152 </TooltipPopup>
1159 onClick={(event) => {1153 </Tooltip>
1160 openPrLink(event, prStatus.url);1154 </div>
1161 }}1155
1162 >1156 <CollapsibleContent>
1163 <GitPullRequestIcon className="size-3" />1157 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
1164 </button>1158 {visibleThreads.map((thread) => {
1165 }1159 const isActive = routeThreadId === thread.id;
1166 />1160 const threadStatus = threadStatusPill(
1167 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>1161 thread,
1168 </Tooltip>1162 pendingApprovalByThreadId.get(thread.id) === true,
1169 )}1163 );
1170 {threadStatus && (1164 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
1171 <span1165 const terminalStatus = terminalStatusFromRunningIds(
1172 className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}1166 selectThreadTerminalState(terminalStateByThreadId, thread.id)
1173 >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 && (
1174 <span1224 <span
1175 className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${1225 className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
1176 threadStatus.pulse ? "animate-pulse" : ""1226 >
1177 }`}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()}
1178 />1265 />
1179 <span className="hidden md:inline">{threadStatus.label}</span>1266 ) : (
1180 </span>1267 <span className="min-w-0 flex-1 truncate text-xs">
1181 )}1268 {thread.title}
1182 {renamingThreadId === thread.id ? (1269 </span>
1183 <input1270 )}
1184 ref={(el) => {1271 </div>
1185 if (el && renamingInputRef.current !== el) {1272 <div className="ml-auto flex shrink-0 items-center gap-1.5">
1186 renamingInputRef.current = el;1273 {terminalStatus && (
1187 el.focus();1274 <span
1188 el.select();1275 role="img"
1189 }1276 aria-label={terminalStatus.label}
1190 }}1277 title={terminalStatus.label}
1191 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"1278 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
1192 value={renamingTitle}1279 >
1193 onChange={(e) => setRenamingTitle(e.target.value)}1280 <TerminalIcon
1194 onKeyDown={(e) => {1281 className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1195 e.stopPropagation();1282 />
1196 if (e.key === "Enter") {1283 </span>
1197 e.preventDefault();1284 )}
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 && (
1221 <span1285 <span
1222 role="img"1286 className={`text-[10px] ${
1223 aria-label={terminalStatus.label}1287 isActive ? "text-foreground/65" : "text-muted-foreground/40"
1224 title={terminalStatus.label}1288 }`}
1225 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
1226 >1289 >
1227 <TerminalIcon1290 {formatRelativeTime(thread.createdAt)}
1228 className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1229 />
1230 </span>1291 </span>
1231 )}1292 </div>
1232 <span1293 </SidebarMenuSubButton>
1233 className={`text-[10px] ${1294 </SidebarMenuSubItem>
1234 isActive ? "text-foreground/65" : "text-muted-foreground/40"1295 );
1235 }`}1296 })}
1236 >1297
1237 {formatRelativeTime(thread.createdAt)}1298 {hasHiddenThreads && !isThreadListExpanded && (
1238 </span>1299 <SidebarMenuSubItem className="w-full">
1239 </div>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>
1240 </SidebarMenuSubButton>1309 </SidebarMenuSubButton>
1241 </SidebarMenuSubItem>1310 </SidebarMenuSubItem>
1242 );1311 )}
1243 })}1312 {hasHiddenThreads && isThreadListExpanded && (
1244 1313 <SidebarMenuSubItem className="w-full">
1245 {hasHiddenThreads && !isThreadListExpanded && (1314 <SidebarMenuSubButton
1246 <SidebarMenuSubItem className="w-full">1315 render={<button type="button" />}
1247 <SidebarMenuSubButton1316 size="sm"
1248 render={<button type="button" />}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"
1249 size="sm"1318 onClick={() => {
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"1319 collapseThreadListForProject(project.id);
1251 onClick={() => {1320 }}
1252 expandThreadListForProject(project.id);1321 >
1253 }}1322 <span>Show less</span>
1254 >1323 </SidebarMenuSubButton>
1255 <span>Show more</span>1324 </SidebarMenuSubItem>
1256 </SidebarMenuSubButton>1325 )}
1257 </SidebarMenuSubItem>1326 </SidebarMenuSub>
1258 )}1327 </CollapsibleContent>
1259 {hasHiddenThreads && isThreadListExpanded && (1328 </SidebarMenuItem>
1260 <SidebarMenuSubItem className="w-full">1329 </Collapsible>
1261 <SidebarMenuSubButton1330 {draggedProjectId !== null &&
1262 render={<button type="button" />}1331 projectDropIndex === projectIndex + 1 &&
1263 size="sm"1332 projectIndex === projects.length - 1 && (
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"1333 <SidebarMenuItem aria-hidden className="pointer-events-none px-2 py-0.5">
1265 onClick={() => {1334 <div className="h-0.5 w-full rounded-full bg-primary/80" />
1266 collapseThreadListForProject(project.id);1335 </SidebarMenuItem>
1267 }}1336 )}
1268 >1337 </Fragment>
1269 <span>Show less</span>
1270 </SidebarMenuSubButton>
1271 </SidebarMenuSubItem>
1272 )}
1273 </SidebarMenuSub>
1274 </CollapsibleContent>
1275 </SidebarMenuItem>
1276 </Collapsible>
1277 );1338 );
1278 })}1339 })}
1279 </SidebarMenu>1340 </SidebarMenu>
apps/web/src/store.test.ts+72−14
@@ -1,8 +1,19 @@
1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";1import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
3 3
4import { markThreadUnread, type AppState } from "./store";4import { markThreadUnread, reorderProjects, syncServerReadModel, type AppState } from "./store";
5import type { Thread } from "./types";5import type { Project, Thread } from "./types";
6
7function 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}
6 17
7function makeThread(overrides: Partial<Thread> = {}): Thread {18function makeThread(overrides: Partial<Thread> = {}): Thread {
8 return {19 return {
@@ -24,25 +35,72 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 15 unmodified lines
24 };35 };
25}36}
26 37
27function makeState(thread: Thread): AppState {38function makeState(thread: Thread, projects: Project[] = [makeProject("project-1")]): AppState {
28 return {39 return {
29 projects: [40 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 ],
39 threads: [thread],41 threads: [thread],
40 threadsHydrated: true,42 threadsHydrated: true,
41 runtimeMode: "full-access",43 runtimeMode: "full-access",
42 };44 };
43}45}
44 46
47function 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
45describe("store pure functions", () => {65describe("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
46 it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {104 it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {
47 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";105 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
48 const initialState = makeState(106 const initialState = makeState(
apps/web/src/store.ts+52−3
@@ -44,24 +44,32 @@ const initialState: AppState = { · 43 unmodified lines
44 runtimeMode: DEFAULT_RUNTIME_MODE,44 runtimeMode: DEFAULT_RUNTIME_MODE,
45};45};
46const persistedExpandedProjectCwds = new Set<string>();46const persistedExpandedProjectCwds = new Set<string>();
47const persistedProjectOrderCwds: string[] = [];
47 48
48// ── Persist helpers ──────────────────────────────────────────────────49// ── Persist helpers ──────────────────────────────────────────────────
49 50
50function readPersistedState(): AppState {51function readPersistedState(): AppState {
51 if (typeof window === "undefined") return initialState;52 if (typeof window === "undefined") return initialState;
52 try {53 try {
54 persistedExpandedProjectCwds.clear();
55 persistedProjectOrderCwds.length = 0;
53 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);56 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
54 if (!raw) return initialState;57 if (!raw) return initialState;
55 const parsed = JSON.parse(raw) as {58 const parsed = JSON.parse(raw) as {
56 runtimeMode?: RuntimeMode;59 runtimeMode?: RuntimeMode;
57 expandedProjectCwds?: string[];60 expandedProjectCwds?: string[];
61 projectOrderCwds?: string[];
58 };62 };
59 persistedExpandedProjectCwds.clear();
60 for (const cwd of parsed.expandedProjectCwds ?? []) {63 for (const cwd of parsed.expandedProjectCwds ?? []) {
61 if (typeof cwd === "string" && cwd.length > 0) {64 if (typeof cwd === "string" && cwd.length > 0) {
62 persistedExpandedProjectCwds.add(cwd);65 persistedExpandedProjectCwds.add(cwd);
63 }66 }
64 }67 }
68 for (const cwd of parsed.projectOrderCwds ?? []) {
69 if (typeof cwd === "string" && cwd.length > 0) {
70 persistedProjectOrderCwds.push(cwd);
71 }
72 }
65 return {73 return {
66 ...initialState,74 ...initialState,
67 runtimeMode:75 runtimeMode:
@@ -84,6 +92,7 @@ function persistState(state: AppState): void { · 16 unmodified lines
84 expandedProjectCwds: state.projects92 expandedProjectCwds: state.projects
85 .filter((project) => project.expanded)93 .filter((project) => project.expanded)
86 .map((project) => project.cwd),94 .map((project) => project.cwd),
95 projectOrderCwds: state.projects.map((project) => project.cwd),
87 }),96 }),
88 );97 );
89 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {98 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
@@ -115,7 +124,7 @@ function mapProjectsFromReadModel( · 25 unmodified lines
115 incoming: OrchestrationReadModel["projects"],124 incoming: OrchestrationReadModel["projects"],
116 previous: Project[],125 previous: Project[],
117): Project[] {126): Project[] {
118 return incoming.map((project) => {127 const mappedProjects = incoming.map((project) => {
119 const existing =128 const existing =
120 previous.find((entry) => entry.id === project.id) ??129 previous.find((entry) => entry.id === project.id) ??
121 previous.find((entry) => entry.cwd === project.workspaceRoot);130 previous.find((entry) => entry.cwd === project.workspaceRoot);
@@ -132,6 +141,25 @@ function mapProjectsFromReadModel( · 10 unmodified lines
132 scripts: project.scripts.map((script) => ({ ...script })),141 scripts: project.scripts.map((script) => ({ ...script })),
133 };142 };
134 });143 });
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;
135}163}
136 164
137function toLegacySessionStatus(165function toLegacySessionStatus(
@@ -333,6 +361,24 @@ export function setProjectExpanded( · 195 unmodified lines
333 return changed ? { ...state, projects } : state;361 return changed ? { ...state, projects } : state;
334}362}
335 363
364export 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
336export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {382export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
337 const threads = updateThread(state.threads, threadId, (t) => {383 const threads = updateThread(state.threads, threadId, (t) => {
338 if (t.error === error) return t;384 if (t.error === error) return t;
@@ -373,6 +419,7 @@ interface AppStore extends AppState { · 34 unmodified lines
373 markThreadUnread: (threadId: ThreadId) => void;419 markThreadUnread: (threadId: ThreadId) => void;
374 toggleProject: (projectId: Project["id"]) => void;420 toggleProject: (projectId: Project["id"]) => void;
375 setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;421 setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
422 reorderProjects: (sourceProjectId: Project["id"], destinationIndex: number) => void;
376 setError: (threadId: ThreadId, error: string | null) => void;423 setError: (threadId: ThreadId, error: string | null) => void;
377 setThreadBranch: (424 setThreadBranch: (
378 threadId: ThreadId,425 threadId: ThreadId,
@@ -394,6 +441,8 @@ export const useStore = create<AppStore>((set) => ({ · 15 unmodified lines
394 set((state) => toggleProject(state, projectId)),441 set((state) => toggleProject(state, projectId)),
395 setProjectExpanded: (projectId, expanded) =>442 setProjectExpanded: (projectId, expanded) =>
396 set((state) => setProjectExpanded(state, projectId, expanded)),443 set((state) => setProjectExpanded(state, projectId, expanded)),
444 reorderProjects: (sourceProjectId, destinationIndex) =>
445 set((state) => reorderProjects(state, sourceProjectId, destinationIndex)),
397 setError: (threadId, error) =>446 setError: (threadId, error) =>
398 set((state) => setError(state, threadId, error)),447 set((state) => setError(state, threadId, error)),
399 setThreadBranch: (threadId, branch, worktreePath) =>448 setThreadBranch: (threadId, branch, worktreePath) =>
@@ -402,7 +451,7 @@ export const useStore = create<AppStore>((set) => ({ · 2 unmodified lines
402 set((state) => setRuntimeMode(state, mode)),451 set((state) => setRuntimeMode(state, mode)),
403}));452}));
404 453
405// Persist on every state change (only runtimeMode + expandedProjectCwds)454// Persist on every state change (runtimeMode + sidebar project UI state)
406useStore.subscribe((state) => persistState(state));455useStore.subscribe((state) => persistState(state));
407 456
408export function StoreProvider({ children }: { children: ReactNode }) {457export function StoreProvider({ children }: { children: ReactNode }) {