Skip to content

Commit a221542

Browse files
Add project rename support in the sidebar (#1798)
1 parent 226ed99 commit a221542

2 files changed

Lines changed: 152 additions & 17 deletions

File tree

apps/web/src/components/Sidebar.tsx

Lines changed: 131 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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,30 +401,24 @@ 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)}
413408
onKeyDown={(event) => {
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,13 +713,17 @@ 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);
724721
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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);

0 commit comments

Comments
 (0)