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 {
267267 renamingThreadId: ThreadId | null;
268268 renamingTitle: string;
269269 setRenamingTitle: (title: string) => void;
270 renamingInputRef: MutableRefObject<HTMLInputElement | null>;
271 renamingCommittedRef: MutableRefObject<boolean>;
270 onRenamingInputMount: (element: HTMLInputElement | null) => void;
271 hasRenameCommitted: () => boolean;
272 markRenameCommitted: () => void;
272273 confirmingArchiveThreadId: ThreadId | null;
273274 setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>;
274275 confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>;
......@@ -400,13 +401,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
400401 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
401402 {props.renamingThreadId === thread.id ? (
402403 <input
403 ref={(element) => {
404 if (element && props.renamingInputRef.current !== element) {
405 props.renamingInputRef.current = element;
406 element.focus();
407 element.select();
408 }
409 }}
404 ref={props.onRenamingInputMount}
410405 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
411406 value={props.renamingTitle}
412407 onChange={(event) => props.setRenamingTitle(event.target.value)}
......@@ -414,16 +409,16 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
414409 event.stopPropagation();
415410 if (event.key === "Enter") {
416411 event.preventDefault();
417 props.renamingCommittedRef.current = true;
412 props.markRenameCommitted();
418413 void props.commitRename(thread.id, props.renamingTitle, thread.title);
419414 } else if (event.key === "Escape") {
420415 event.preventDefault();
421 props.renamingCommittedRef.current = true;
416 props.markRenameCommitted();
422417 props.cancelRename();
423418 }
424419 }}
425420 onBlur={() => {
426 if (!props.renamingCommittedRef.current) {
421 if (!props.hasRenameCommitted()) {
427422 void props.commitRename(thread.id, props.renamingTitle, thread.title);
428423 }
429424 }}
......@@ -718,6 +713,8 @@ export default function Sidebar() {
718713 const [isAddingProject, setIsAddingProject] = useState(false);
719714 const [addProjectError, setAddProjectError] = useState<string | null>(null);
720715 const addProjectInputRef = useRef<HTMLInputElement | null>(null);
716 const [renamingProjectId, setRenamingProjectId] = useState<ProjectId | null>(null);
717 const [renamingProjectTitle, setRenamingProjectTitle] = useState("");
721718 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
722719 const [renamingTitle, setRenamingTitle] = useState("");
723720 const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);
......@@ -725,6 +722,8 @@ export default function Sidebar() {
725722 ReadonlySet<ProjectId>
726723 >(() => new Set());
727724 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
725 const projectRenamingCommittedRef = useRef(false);
726 const projectRenamingInputRef = useRef<HTMLInputElement | null>(null);
728727 const renamingCommittedRef = useRef(false);
729728 const renamingInputRef = useRef<HTMLInputElement | null>(null);
730729 const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>());
......@@ -937,6 +936,28 @@ export default function Sidebar() {
937936 renamingInputRef.current = null;
938937 }, []);
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
940961 const commitRename = useCallback(
941962 async (threadId: ThreadId, newTitle: string, originalTitle: string) => {
942963 const finishRename = () => {
......@@ -984,6 +1005,53 @@ export default function Sidebar() {
9841005 [],
9851006 );
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
9871055 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
9881056 threadId: ThreadId;
9891057 }>({
......@@ -1040,6 +1108,8 @@ export default function Sidebar() {
10401108 );
10411109
10421110 if (clicked === "rename") {
1111 setRenamingProjectId(null);
1112 projectRenamingInputRef.current = null;
10431113 setRenamingThreadId(threadId);
10441114 setRenamingTitle(thread.title);
10451115 renamingCommittedRef.current = false;
......@@ -1206,11 +1276,20 @@ export default function Sidebar() {
12061276
12071277 const clicked = await api.contextMenu.show(
12081278 [
1279 { id: "rename", label: "Rename project" },
12091280 { id: "copy-path", label: "Copy Project Path" },
12101281 { id: "delete", label: "Remove project", destructive: true },
12111282 ],
12121283 position,
12131284 );
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 }
12141293 if (clicked === "copy-path") {
12151294 copyPathToClipboard(project.cwd, { path: project.cwd });
12161295 return;
......@@ -1602,9 +1681,43 @@ export default function Sidebar() {
16021681 />
16031682 )}
16041683 <ProjectFavicon cwd={project.cwd} />
1605 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1606 {project.name}
1607 </span>
1684 {renamingProjectId === project.id ? (
1685 <input
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 )}
16081721 </SidebarMenuButton>
16091722 <Tooltip>
16101723 <TooltipTrigger
......@@ -1693,8 +1806,9 @@ export default function Sidebar() {
16931806 renamingThreadId={renamingThreadId}
16941807 renamingTitle={renamingTitle}
16951808 setRenamingTitle={setRenamingTitle}
1696 renamingInputRef={renamingInputRef}
1697 renamingCommittedRef={renamingCommittedRef}
1809 onRenamingInputMount={handleRenamingInputMount}
1810 hasRenameCommitted={hasRenameCommitted}
1811 markRenameCommitted={markRenameCommitted}
16981812 confirmingArchiveThreadId={confirmingArchiveThreadId}
16991813 setConfirmingArchiveThreadId={setConfirmingArchiveThreadId}
17001814 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
apps/web/src/store.test.ts+21
......@@ -328,6 +328,27 @@ describe("incremental orchestration updates", () => {
328328 expect(next.bootstrapComplete).toBe(false);
329329 });
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
331352 it("preserves state identity for no-op project and thread deletes", () => {
332353 const thread = makeThread();
333354 const state = makeState(thread);