| ... | ... | @@ -267,8 +267,9 @@ interface SidebarThreadRowProps { |
| 267 | 267 | renamingThreadId: ThreadId | null; |
| 268 | 268 | renamingTitle: string; |
| 269 | 269 | 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; |
| 272 | 273 | confirmingArchiveThreadId: ThreadId | null; |
| 273 | 274 | setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>; |
| 274 | 275 | confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>; |
| ... | ... | @@ -400,13 +401,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 400 | 401 | {threadStatus && <ThreadStatusLabel status={threadStatus} />} |
| 401 | 402 | {props.renamingThreadId === thread.id ? ( |
| 402 | 403 | <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} |
| 410 | 405 | className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" |
| 411 | 406 | value={props.renamingTitle} |
| 412 | 407 | onChange={(event) => props.setRenamingTitle(event.target.value)} |
| ... | ... | @@ -414,16 +409,16 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 414 | 409 | event.stopPropagation(); |
| 415 | 410 | if (event.key === "Enter") { |
| 416 | 411 | event.preventDefault(); |
| 417 | | props.renamingCommittedRef.current = true; |
| 412 | props.markRenameCommitted(); |
| 418 | 413 | void props.commitRename(thread.id, props.renamingTitle, thread.title); |
| 419 | 414 | } else if (event.key === "Escape") { |
| 420 | 415 | event.preventDefault(); |
| 421 | | props.renamingCommittedRef.current = true; |
| 416 | props.markRenameCommitted(); |
| 422 | 417 | props.cancelRename(); |
| 423 | 418 | } |
| 424 | 419 | }} |
| 425 | 420 | onBlur={() => { |
| 426 | | if (!props.renamingCommittedRef.current) { |
| 421 | if (!props.hasRenameCommitted()) { |
| 427 | 422 | void props.commitRename(thread.id, props.renamingTitle, thread.title); |
| 428 | 423 | } |
| 429 | 424 | }} |
| ... | ... | @@ -718,6 +713,8 @@ export default function Sidebar() { |
| 718 | 713 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 719 | 714 | const [addProjectError, setAddProjectError] = useState<string | null>(null); |
| 720 | 715 | const addProjectInputRef = useRef<HTMLInputElement | null>(null); |
| 716 | const [renamingProjectId, setRenamingProjectId] = useState<ProjectId | null>(null); |
| 717 | const [renamingProjectTitle, setRenamingProjectTitle] = useState(""); |
| 721 | 718 | const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); |
| 722 | 719 | const [renamingTitle, setRenamingTitle] = useState(""); |
| 723 | 720 | const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null); |
| ... | ... | @@ -725,6 +722,8 @@ export default function Sidebar() { |
| 725 | 722 | ReadonlySet<ProjectId> |
| 726 | 723 | >(() => new Set()); |
| 727 | 724 | const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); |
| 725 | const projectRenamingCommittedRef = useRef(false); |
| 726 | const projectRenamingInputRef = useRef<HTMLInputElement | null>(null); |
| 728 | 727 | const renamingCommittedRef = useRef(false); |
| 729 | 728 | const renamingInputRef = useRef<HTMLInputElement | null>(null); |
| 730 | 729 | const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>()); |
| ... | ... | @@ -937,6 +936,28 @@ export default function Sidebar() { |
| 937 | 936 | renamingInputRef.current = null; |
| 938 | 937 | }, []); |
| 939 | 938 | |
| 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 | 961 | const commitRename = useCallback( |
| 941 | 962 | async (threadId: ThreadId, newTitle: string, originalTitle: string) => { |
| 942 | 963 | const finishRename = () => { |
| ... | ... | @@ -984,6 +1005,53 @@ export default function Sidebar() { |
| 984 | 1005 | [], |
| 985 | 1006 | ); |
| 986 | 1007 | |
| 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 | 1055 | const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ |
| 988 | 1056 | threadId: ThreadId; |
| 989 | 1057 | }>({ |
| ... | ... | @@ -1040,6 +1108,8 @@ export default function Sidebar() { |
| 1040 | 1108 | ); |
| 1041 | 1109 | |
| 1042 | 1110 | if (clicked === "rename") { |
| 1111 | setRenamingProjectId(null); |
| 1112 | projectRenamingInputRef.current = null; |
| 1043 | 1113 | setRenamingThreadId(threadId); |
| 1044 | 1114 | setRenamingTitle(thread.title); |
| 1045 | 1115 | renamingCommittedRef.current = false; |
| ... | ... | @@ -1206,11 +1276,20 @@ export default function Sidebar() { |
| 1206 | 1276 | |
| 1207 | 1277 | const clicked = await api.contextMenu.show( |
| 1208 | 1278 | [ |
| 1279 | { id: "rename", label: "Rename project" }, |
| 1209 | 1280 | { id: "copy-path", label: "Copy Project Path" }, |
| 1210 | 1281 | { id: "delete", label: "Remove project", destructive: true }, |
| 1211 | 1282 | ], |
| 1212 | 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 | 1293 | if (clicked === "copy-path") { |
| 1215 | 1294 | copyPathToClipboard(project.cwd, { path: project.cwd }); |
| 1216 | 1295 | return; |
| ... | ... | @@ -1602,9 +1681,43 @@ export default function Sidebar() { |
| 1602 | 1681 | /> |
| 1603 | 1682 | )} |
| 1604 | 1683 | <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 | )} |
| 1608 | 1721 | </SidebarMenuButton> |
| 1609 | 1722 | <Tooltip> |
| 1610 | 1723 | <TooltipTrigger |
| ... | ... | @@ -1693,8 +1806,9 @@ export default function Sidebar() { |
| 1693 | 1806 | renamingThreadId={renamingThreadId} |
| 1694 | 1807 | renamingTitle={renamingTitle} |
| 1695 | 1808 | setRenamingTitle={setRenamingTitle} |
| 1696 | | renamingInputRef={renamingInputRef} |
| 1697 | | renamingCommittedRef={renamingCommittedRef} |
| 1809 | onRenamingInputMount={handleRenamingInputMount} |
| 1810 | hasRenameCommitted={hasRenameCommitted} |
| 1811 | markRenameCommitted={markRenameCommitted} |
| 1698 | 1812 | confirmingArchiveThreadId={confirmingArchiveThreadId} |
| 1699 | 1813 | setConfirmingArchiveThreadId={setConfirmingArchiveThreadId} |
| 1700 | 1814 | confirmArchiveButtonRefs={confirmArchiveButtonRefs} |