diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d227e3a803..5b4da4655c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -267,8 +267,9 @@ interface SidebarThreadRowProps { renamingThreadId: ThreadId | null; renamingTitle: string; setRenamingTitle: (title: string) => void; - renamingInputRef: MutableRefObject; - renamingCommittedRef: MutableRefObject; + onRenamingInputMount: (element: HTMLInputElement | null) => void; + hasRenameCommitted: () => boolean; + markRenameCommitted: () => void; confirmingArchiveThreadId: ThreadId | null; setConfirmingArchiveThreadId: Dispatch>; confirmArchiveButtonRefs: MutableRefObject>; @@ -400,13 +401,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { {threadStatus && } {props.renamingThreadId === thread.id ? ( { - if (element && props.renamingInputRef.current !== element) { - props.renamingInputRef.current = element; - element.focus(); - element.select(); - } - }} + ref={props.onRenamingInputMount} className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" value={props.renamingTitle} onChange={(event) => props.setRenamingTitle(event.target.value)} @@ -414,16 +409,16 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); - props.renamingCommittedRef.current = true; + props.markRenameCommitted(); void props.commitRename(thread.id, props.renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); - props.renamingCommittedRef.current = true; + props.markRenameCommitted(); props.cancelRename(); } }} onBlur={() => { - if (!props.renamingCommittedRef.current) { + if (!props.hasRenameCommitted()) { void props.commitRename(thread.id, props.renamingTitle, thread.title); } }} @@ -718,6 +713,8 @@ export default function Sidebar() { const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); + const [renamingProjectId, setRenamingProjectId] = useState(null); + const [renamingProjectTitle, setRenamingProjectTitle] = useState(""); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState(null); @@ -725,6 +722,8 @@ export default function Sidebar() { ReadonlySet >(() => new Set()); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const projectRenamingCommittedRef = useRef(false); + const projectRenamingInputRef = useRef(null); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const confirmArchiveButtonRefs = useRef(new Map()); @@ -937,6 +936,28 @@ export default function Sidebar() { renamingInputRef.current = null; }, []); + const handleRenamingInputMount = useCallback((element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + return; + } + if (element === null && renamingInputRef.current !== null) { + renamingInputRef.current = null; + } + }, []); + + const hasRenameCommitted = useCallback(() => renamingCommittedRef.current, []); + const markRenameCommitted = useCallback(() => { + renamingCommittedRef.current = true; + }, []); + + const cancelProjectRename = useCallback(() => { + setRenamingProjectId(null); + projectRenamingInputRef.current = null; + }, []); + const commitRename = useCallback( async (threadId: ThreadId, newTitle: string, originalTitle: string) => { const finishRename = () => { @@ -984,6 +1005,53 @@ export default function Sidebar() { [], ); + const commitProjectRename = useCallback( + async (projectId: ProjectId, newTitle: string, originalTitle: string) => { + const finishRename = () => { + setRenamingProjectId((current) => { + if (current !== projectId) return current; + projectRenamingInputRef.current = null; + return null; + }); + }; + + const trimmed = newTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Project title cannot be empty", + }); + finishRename(); + return; + } + if (trimmed === originalTitle) { + finishRename(); + return; + } + const api = readNativeApi(); + if (!api) { + finishRename(); + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId, + title: trimmed, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename project", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + finishRename(); + }, + [], + ); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId; }>({ @@ -1040,6 +1108,8 @@ export default function Sidebar() { ); if (clicked === "rename") { + setRenamingProjectId(null); + projectRenamingInputRef.current = null; setRenamingThreadId(threadId); setRenamingTitle(thread.title); renamingCommittedRef.current = false; @@ -1206,11 +1276,20 @@ export default function Sidebar() { const clicked = await api.contextMenu.show( [ + { id: "rename", label: "Rename project" }, { id: "copy-path", label: "Copy Project Path" }, { id: "delete", label: "Remove project", destructive: true }, ], position, ); + if (clicked === "rename") { + setRenamingThreadId(null); + renamingInputRef.current = null; + setRenamingProjectId(projectId); + setRenamingProjectTitle(project.name); + projectRenamingCommittedRef.current = false; + return; + } if (clicked === "copy-path") { copyPathToClipboard(project.cwd, { path: project.cwd }); return; @@ -1602,9 +1681,43 @@ export default function Sidebar() { /> )} - - {project.name} - + {renamingProjectId === project.id ? ( + { + if (element && projectRenamingInputRef.current !== element) { + projectRenamingInputRef.current = element; + element.focus(); + element.select(); + } + }} + 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" + value={renamingProjectTitle} + onChange={(event) => setRenamingProjectTitle(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + projectRenamingCommittedRef.current = true; + void commitProjectRename(project.id, renamingProjectTitle, project.name); + } else if (event.key === "Escape") { + event.preventDefault(); + projectRenamingCommittedRef.current = true; + cancelProjectRename(); + } + }} + onBlur={() => { + if (!projectRenamingCommittedRef.current) { + void commitProjectRename(project.id, renamingProjectTitle, project.name); + } + }} + onClick={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + /> + ) : ( + + {project.name} + + )} { expect(next.bootstrapComplete).toBe(false); }); + it("updates the existing project title when project.meta-updated arrives", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + const state = makeState( + makeThread({ + projectId, + }), + ); + + const next = applyOrchestrationEvent( + state, + makeEvent("project.meta-updated", { + projectId, + title: "Renamed Project", + updatedAt: "2026-02-27T00:00:01.000Z", + }), + ); + + expect(next.projects[0]?.name).toBe("Renamed Project"); + expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:01.000Z"); + }); + it("preserves state identity for no-op project and thread deletes", () => { const thread = makeThread(); const state = makeState(thread);