| ... | @@ -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 | <input | 403 | <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 | }, []); |
| 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 | 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 | ); |
| 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 | 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 | ); |
| 1041 | | 1109 | |
| 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() { |
| 1206 | | 1276 | |
| 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 | <TooltipTrigger | 1723 | <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} |