authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-06 16:06:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-06 16:06:36-07:00
loga221542921fd01271f67c6bfa2a0fe7f7ff78dc3
tree29641d5db3f9a5d5183a769be5df5f0344e8771b
parent226ed997e1a6493e6b29d5264e0b0f8173e7c630
signature Signed by PGP key B5690EEEBB952194

Add project rename support in the sidebar (#1798)


2 files changed, 152 insertions(+), 17 deletions(-)

apps/web/src/components/Sidebar.tsx+131-17
...@@ -267,8 +267,9 @@ interface SidebarThreadRowProps {...@@ -267,8 +267,9 @@ interface SidebarThreadRowProps {
267 renamingThreadId: ThreadId | null;267 renamingThreadId: ThreadId | null;
268 renamingTitle: string;268 renamingTitle: string;
269 setRenamingTitle: (title: string) => void;269 setRenamingTitle: (title: string) => void;
270 renamingInputRef: MutableRefObject<HTMLInputElement | null>;270 onRenamingInputMount: (element: HTMLInputElement | null) => void;
271 renamingCommittedRef: MutableRefObject<boolean>;271 hasRenameCommitted: () => boolean;
272 markRenameCommitted: () => void;
272 confirmingArchiveThreadId: ThreadId | null;273 confirmingArchiveThreadId: ThreadId | null;
273 setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>;274 setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>;
274 confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>;275 confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>;
...@@ -400,13 +401,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {...@@ -400,13 +401,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
400 {threadStatus && <ThreadStatusLabel status={threadStatus} />}401 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
401 {props.renamingThreadId === thread.id ? (402 {props.renamingThreadId === thread.id ? (
402 <input403 <input
403 ref={(element) => {404 ref={props.onRenamingInputMount}
404 if (element && props.renamingInputRef.current !== element) {
405 props.renamingInputRef.current = element;
406 element.focus();
407 element.select();
408 }
409 }}
410 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"405 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
411 value={props.renamingTitle}406 value={props.renamingTitle}
412 onChange={(event) => props.setRenamingTitle(event.target.value)}407 onChange={(event) => props.setRenamingTitle(event.target.value)}
...@@ -414,16 +409,16 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {...@@ -414,16 +409,16 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
414 event.stopPropagation();409 event.stopPropagation();
415 if (event.key === "Enter") {410 if (event.key === "Enter") {
416 event.preventDefault();411 event.preventDefault();
417 props.renamingCommittedRef.current = true;412 props.markRenameCommitted();
418 void props.commitRename(thread.id, props.renamingTitle, thread.title);413 void props.commitRename(thread.id, props.renamingTitle, thread.title);
419 } else if (event.key === "Escape") {414 } else if (event.key === "Escape") {
420 event.preventDefault();415 event.preventDefault();
421 props.renamingCommittedRef.current = true;416 props.markRenameCommitted();
422 props.cancelRename();417 props.cancelRename();
423 }418 }
424 }}419 }}
425 onBlur={() => {420 onBlur={() => {
426 if (!props.renamingCommittedRef.current) {421 if (!props.hasRenameCommitted()) {
427 void props.commitRename(thread.id, props.renamingTitle, thread.title);422 void props.commitRename(thread.id, props.renamingTitle, thread.title);
428 }423 }
429 }}424 }}
...@@ -718,6 +713,8 @@ export default function Sidebar() {...@@ -718,6 +713,8 @@ export default function Sidebar() {
718 const [isAddingProject, setIsAddingProject] = useState(false);713 const [isAddingProject, setIsAddingProject] = useState(false);
719 const [addProjectError, setAddProjectError] = useState<string | null>(null);714 const [addProjectError, setAddProjectError] = useState<string | null>(null);
720 const addProjectInputRef = useRef<HTMLInputElement | null>(null);715 const addProjectInputRef = useRef<HTMLInputElement | null>(null);
716 const [renamingProjectId, setRenamingProjectId] = useState<ProjectId | null>(null);
717 const [renamingProjectTitle, setRenamingProjectTitle] = useState("");
721 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);718 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
722 const [renamingTitle, setRenamingTitle] = useState("");719 const [renamingTitle, setRenamingTitle] = useState("");
723 const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);720 const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);
...@@ -725,6 +722,8 @@ export default function Sidebar() {...@@ -725,6 +722,8 @@ export default function Sidebar() {
725 ReadonlySet<ProjectId>722 ReadonlySet<ProjectId>
726 >(() => new Set());723 >(() => new Set());
727 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();724 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
725 const projectRenamingCommittedRef = useRef(false);
726 const projectRenamingInputRef = useRef<HTMLInputElement | null>(null);
728 const renamingCommittedRef = useRef(false);727 const renamingCommittedRef = useRef(false);
729 const renamingInputRef = useRef<HTMLInputElement | null>(null);728 const renamingInputRef = useRef<HTMLInputElement | null>(null);
730 const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>());729 const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>());
...@@ -937,6 +936,28 @@ export default function Sidebar() {...@@ -937,6 +936,28 @@ export default function Sidebar() {
937 renamingInputRef.current = null;936 renamingInputRef.current = null;
938 }, []);937 }, []);
939938
939 const handleRenamingInputMount = useCallback((element: HTMLInputElement | null) => {
940 if (element && renamingInputRef.current !== element) {
941 renamingInputRef.current = element;
942 element.focus();
943 element.select();
944 return;
945 }
946 if (element === null && renamingInputRef.current !== null) {
947 renamingInputRef.current = null;
948 }
949 }, []);
950
951 const hasRenameCommitted = useCallback(() => renamingCommittedRef.current, []);
952 const markRenameCommitted = useCallback(() => {
953 renamingCommittedRef.current = true;
954 }, []);
955
956 const cancelProjectRename = useCallback(() => {
957 setRenamingProjectId(null);
958 projectRenamingInputRef.current = null;
959 }, []);
960
940 const commitRename = useCallback(961 const commitRename = useCallback(
941 async (threadId: ThreadId, newTitle: string, originalTitle: string) => {962 async (threadId: ThreadId, newTitle: string, originalTitle: string) => {
942 const finishRename = () => {963 const finishRename = () => {
...@@ -984,6 +1005,53 @@ export default function Sidebar() {...@@ -984,6 +1005,53 @@ export default function Sidebar() {
984 [],1005 [],
985 );1006 );
9861007
1008 const commitProjectRename = useCallback(
1009 async (projectId: ProjectId, newTitle: string, originalTitle: string) => {
1010 const finishRename = () => {
1011 setRenamingProjectId((current) => {
1012 if (current !== projectId) return current;
1013 projectRenamingInputRef.current = null;
1014 return null;
1015 });
1016 };
1017
1018 const trimmed = newTitle.trim();
1019 if (trimmed.length === 0) {
1020 toastManager.add({
1021 type: "warning",
1022 title: "Project title cannot be empty",
1023 });
1024 finishRename();
1025 return;
1026 }
1027 if (trimmed === originalTitle) {
1028 finishRename();
1029 return;
1030 }
1031 const api = readNativeApi();
1032 if (!api) {
1033 finishRename();
1034 return;
1035 }
1036 try {
1037 await api.orchestration.dispatchCommand({
1038 type: "project.meta.update",
1039 commandId: newCommandId(),
1040 projectId,
1041 title: trimmed,
1042 });
1043 } catch (error) {
1044 toastManager.add({
1045 type: "error",
1046 title: "Failed to rename project",
1047 description: error instanceof Error ? error.message : "An error occurred.",
1048 });
1049 }
1050 finishRename();
1051 },
1052 [],
1053 );
1054
987 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{1055 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
988 threadId: ThreadId;1056 threadId: ThreadId;
989 }>({1057 }>({
...@@ -1040,6 +1108,8 @@ export default function Sidebar() {...@@ -1040,6 +1108,8 @@ export default function Sidebar() {
1040 );1108 );
10411109
1042 if (clicked === "rename") {1110 if (clicked === "rename") {
1111 setRenamingProjectId(null);
1112 projectRenamingInputRef.current = null;
1043 setRenamingThreadId(threadId);1113 setRenamingThreadId(threadId);
1044 setRenamingTitle(thread.title);1114 setRenamingTitle(thread.title);
1045 renamingCommittedRef.current = false;1115 renamingCommittedRef.current = false;
...@@ -1206,11 +1276,20 @@ export default function Sidebar() {...@@ -1206,11 +1276,20 @@ export default function Sidebar() {
12061276
1207 const clicked = await api.contextMenu.show(1277 const clicked = await api.contextMenu.show(
1208 [1278 [
1279 { id: "rename", label: "Rename project" },
1209 { id: "copy-path", label: "Copy Project Path" },1280 { id: "copy-path", label: "Copy Project Path" },
1210 { id: "delete", label: "Remove project", destructive: true },1281 { id: "delete", label: "Remove project", destructive: true },
1211 ],1282 ],
1212 position,1283 position,
1213 );1284 );
1285 if (clicked === "rename") {
1286 setRenamingThreadId(null);
1287 renamingInputRef.current = null;
1288 setRenamingProjectId(projectId);
1289 setRenamingProjectTitle(project.name);
1290 projectRenamingCommittedRef.current = false;
1291 return;
1292 }
1214 if (clicked === "copy-path") {1293 if (clicked === "copy-path") {
1215 copyPathToClipboard(project.cwd, { path: project.cwd });1294 copyPathToClipboard(project.cwd, { path: project.cwd });
1216 return;1295 return;
...@@ -1602,9 +1681,43 @@ export default function Sidebar() {...@@ -1602,9 +1681,43 @@ export default function Sidebar() {
1602 />1681 />
1603 )}1682 )}
1604 <ProjectFavicon cwd={project.cwd} />1683 <ProjectFavicon cwd={project.cwd} />
1605 <span className="flex-1 truncate text-xs font-medium text-foreground/90">1684 {renamingProjectId === project.id ? (
1606 {project.name}1685 <input
1607 </span>1686 ref={(element) => {
1687 if (element && projectRenamingInputRef.current !== element) {
1688 projectRenamingInputRef.current = element;
1689 element.focus();
1690 element.select();
1691 }
1692 }}
1693 className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-xs font-medium text-foreground/90 outline-none"
1694 value={renamingProjectTitle}
1695 onChange={(event) => setRenamingProjectTitle(event.target.value)}
1696 onKeyDown={(event) => {
1697 event.stopPropagation();
1698 if (event.key === "Enter") {
1699 event.preventDefault();
1700 projectRenamingCommittedRef.current = true;
1701 void commitProjectRename(project.id, renamingProjectTitle, project.name);
1702 } else if (event.key === "Escape") {
1703 event.preventDefault();
1704 projectRenamingCommittedRef.current = true;
1705 cancelProjectRename();
1706 }
1707 }}
1708 onBlur={() => {
1709 if (!projectRenamingCommittedRef.current) {
1710 void commitProjectRename(project.id, renamingProjectTitle, project.name);
1711 }
1712 }}
1713 onClick={(event) => event.stopPropagation()}
1714 onPointerDown={(event) => event.stopPropagation()}
1715 />
1716 ) : (
1717 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1718 {project.name}
1719 </span>
1720 )}
1608 </SidebarMenuButton>1721 </SidebarMenuButton>
1609 <Tooltip>1722 <Tooltip>
1610 <TooltipTrigger1723 <TooltipTrigger
...@@ -1693,8 +1806,9 @@ export default function Sidebar() {...@@ -1693,8 +1806,9 @@ export default function Sidebar() {
1693 renamingThreadId={renamingThreadId}1806 renamingThreadId={renamingThreadId}
1694 renamingTitle={renamingTitle}1807 renamingTitle={renamingTitle}
1695 setRenamingTitle={setRenamingTitle}1808 setRenamingTitle={setRenamingTitle}
1696 renamingInputRef={renamingInputRef}1809 onRenamingInputMount={handleRenamingInputMount}
1697 renamingCommittedRef={renamingCommittedRef}1810 hasRenameCommitted={hasRenameCommitted}
1811 markRenameCommitted={markRenameCommitted}
1698 confirmingArchiveThreadId={confirmingArchiveThreadId}1812 confirmingArchiveThreadId={confirmingArchiveThreadId}
1699 setConfirmingArchiveThreadId={setConfirmingArchiveThreadId}1813 setConfirmingArchiveThreadId={setConfirmingArchiveThreadId}
1700 confirmArchiveButtonRefs={confirmArchiveButtonRefs}1814 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
apps/web/src/store.test.ts+21
...@@ -328,6 +328,27 @@ describe("incremental orchestration updates", () => {...@@ -328,6 +328,27 @@ describe("incremental orchestration updates", () => {
328 expect(next.bootstrapComplete).toBe(false);328 expect(next.bootstrapComplete).toBe(false);
329 });329 });
330330
331 it("updates the existing project title when project.meta-updated arrives", () => {
332 const projectId = ProjectId.makeUnsafe("project-1");
333 const state = makeState(
334 makeThread({
335 projectId,
336 }),
337 );
338
339 const next = applyOrchestrationEvent(
340 state,
341 makeEvent("project.meta-updated", {
342 projectId,
343 title: "Renamed Project",
344 updatedAt: "2026-02-27T00:00:01.000Z",
345 }),
346 );
347
348 expect(next.projects[0]?.name).toBe("Renamed Project");
349 expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:01.000Z");
350 });
351
331 it("preserves state identity for no-op project and thread deletes", () => {352 it("preserves state identity for no-op project and thread deletes", () => {
332 const thread = makeThread();353 const thread = makeThread();
333 const state = makeState(thread);354 const state = makeState(thread);