| ... | ... | @@ -3,6 +3,7 @@ import { |
| 3 | 3 | FolderIcon, |
| 4 | 4 | GitPullRequestIcon, |
| 5 | 5 | RocketIcon, |
| 6 | SquarePenIcon, |
| 6 | 7 | TerminalIcon, |
| 7 | 8 | } from "lucide-react"; |
| 8 | 9 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| ... | ... | @@ -21,7 +22,7 @@ import { isElectron } from "../env"; |
| 21 | 22 | import { APP_STAGE_LABEL } from "../branding"; |
| 22 | 23 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; |
| 23 | 24 | import { useStore } from "../store"; |
| 24 | | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; |
| 25 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; |
| 25 | 26 | import { type Thread } from "../types"; |
| 26 | 27 | import { derivePendingApprovals } from "../session-logic"; |
| 27 | 28 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| ... | ... | @@ -46,6 +47,7 @@ import { |
| 46 | 47 | SidebarFooter, |
| 47 | 48 | SidebarGroup, |
| 48 | 49 | SidebarHeader, |
| 50 | SidebarMenuAction, |
| 49 | 51 | SidebarMenu, |
| 50 | 52 | SidebarMenuButton, |
| 51 | 53 | SidebarMenuItem, |
| ... | ... | @@ -59,6 +61,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " |
| 59 | 61 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 60 | 62 | |
| 61 | 63 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 64 | const THREAD_PREVIEW_LIMIT = 6; |
| 62 | 65 | |
| 63 | 66 | async function copyTextToClipboard(text: string): Promise<void> { |
| 64 | 67 | if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) { |
| ... | ... | @@ -291,6 +294,9 @@ export default function Sidebar() { |
| 291 | 294 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 292 | 295 | const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); |
| 293 | 296 | const [renamingTitle, setRenamingTitle] = useState(""); |
| 297 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< |
| 298 | ReadonlySet<ProjectId> |
| 299 | >(() => new Set()); |
| 294 | 300 | const renamingCommittedRef = useRef(false); |
| 295 | 301 | const renamingInputRef = useRef<HTMLInputElement | null>(null); |
| 296 | 302 | const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null); |
| ... | ... | @@ -857,15 +863,6 @@ export default function Sidebar() { |
| 857 | 863 | }; |
| 858 | 864 | }, []); |
| 859 | 865 | |
| 860 | | const onCreateThreadClick = () => { |
| 861 | | if (projects.length === 0) { |
| 862 | | setAddingProject(true); |
| 863 | | return; |
| 864 | | } |
| 865 | | const firstProject = projects[0]; |
| 866 | | if (firstProject) void handleNewThread(firstProject.id); |
| 867 | | }; |
| 868 | | |
| 869 | 866 | const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); |
| 870 | 867 | |
| 871 | 868 | const desktopUpdateTooltip = desktopUpdateState |
| ... | ... | @@ -887,6 +884,12 @@ export default function Sidebar() { |
| 887 | 884 | : shouldHighlightDesktopUpdateError(desktopUpdateState) |
| 888 | 885 | ? "text-rose-500 animate-pulse" |
| 889 | 886 | : "text-amber-500 animate-pulse"; |
| 887 | const newThreadShortcutLabel = useMemo( |
| 888 | () => |
| 889 | shortcutLabelForCommand(keybindings, "chat.newLocal") ?? |
| 890 | shortcutLabelForCommand(keybindings, "chat.new"), |
| 891 | [keybindings], |
| 892 | ); |
| 890 | 893 | |
| 891 | 894 | const handleDesktopUpdateButtonClick = useCallback(() => { |
| 892 | 895 | const bridge = window.desktopBridge; |
| ... | ... | @@ -946,6 +949,24 @@ export default function Sidebar() { |
| 946 | 949 | } |
| 947 | 950 | }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); |
| 948 | 951 | |
| 952 | const expandThreadListForProject = useCallback((projectId: ProjectId) => { |
| 953 | setExpandedThreadListsByProject((current) => { |
| 954 | if (current.has(projectId)) return current; |
| 955 | const next = new Set(current); |
| 956 | next.add(projectId); |
| 957 | return next; |
| 958 | }); |
| 959 | }, []); |
| 960 | |
| 961 | const collapseThreadListForProject = useCallback((projectId: ProjectId) => { |
| 962 | setExpandedThreadListsByProject((current) => { |
| 963 | if (!current.has(projectId)) return current; |
| 964 | const next = new Set(current); |
| 965 | next.delete(projectId); |
| 966 | return next; |
| 967 | }); |
| 968 | }, []); |
| 969 | |
| 949 | 970 | const wordmark = ( |
| 950 | 971 | <div className="flex items-center gap-2"> |
| 951 | 972 | <SidebarTrigger className="shrink-0 md:hidden" /> |
| ... | ... | @@ -961,17 +982,6 @@ export default function Sidebar() { |
| 961 | 982 | </div> |
| 962 | 983 | ); |
| 963 | 984 | |
| 964 | | const newThreadButton = ( |
| 965 | | <button |
| 966 | | type="button" |
| 967 | | className="flex w-full items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent" |
| 968 | | onClick={onCreateThreadClick} |
| 969 | | > |
| 970 | | <span className="text-foreground">+</span> |
| 971 | | New thread |
| 972 | | </button> |
| 973 | | ); |
| 974 | | |
| 975 | 985 | return ( |
| 976 | 986 | <> |
| 977 | 987 | {isElectron ? ( |
| ... | ... | @@ -998,12 +1008,10 @@ export default function Sidebar() { |
| 998 | 1008 | </Tooltip> |
| 999 | 1009 | )} |
| 1000 | 1010 | </SidebarHeader> |
| 1001 | | <div className="px-4 py-2">{newThreadButton}</div> |
| 1002 | 1011 | </> |
| 1003 | 1012 | ) : ( |
| 1004 | 1013 | <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3"> |
| 1005 | 1014 | {wordmark} |
| 1006 | | {newThreadButton} |
| 1007 | 1015 | </SidebarHeader> |
| 1008 | 1016 | )} |
| 1009 | 1017 | |
| ... | ... | @@ -1011,13 +1019,19 @@ export default function Sidebar() { |
| 1011 | 1019 | <SidebarGroup className="px-2 py-2"> |
| 1012 | 1020 | <SidebarMenu> |
| 1013 | 1021 | {projects.map((project) => { |
| 1014 | | const visibleThreads = threads |
| 1022 | const projectThreads = threads |
| 1015 | 1023 | .filter((thread) => thread.projectId === project.id) |
| 1016 | 1024 | .toSorted((a, b) => { |
| 1017 | 1025 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); |
| 1018 | 1026 | if (byDate !== 0) return byDate; |
| 1019 | 1027 | return b.id.localeCompare(a.id); |
| 1020 | 1028 | }); |
| 1029 | const isThreadListExpanded = expandedThreadListsByProject.has(project.id); |
| 1030 | const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; |
| 1031 | const visibleThreads = |
| 1032 | hasHiddenThreads && !isThreadListExpanded |
| 1033 | ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) |
| 1034 | : projectThreads; |
| 1021 | 1035 | |
| 1022 | 1036 | return ( |
| 1023 | 1037 | <Collapsible |
| ... | ... | @@ -1030,34 +1044,61 @@ export default function Sidebar() { |
| 1030 | 1044 | }} |
| 1031 | 1045 | > |
| 1032 | 1046 | <SidebarMenuItem> |
| 1033 | | <CollapsibleTrigger |
| 1034 | | render={ |
| 1035 | | <SidebarMenuButton |
| 1036 | | size="sm" |
| 1037 | | className="gap-2 px-2 py-1.5 text-left hover:bg-accent" |
| 1047 | <div className="group/project-header relative"> |
| 1048 | <CollapsibleTrigger |
| 1049 | render={ |
| 1050 | <SidebarMenuButton |
| 1051 | size="sm" |
| 1052 | className="gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground" |
| 1053 | /> |
| 1054 | } |
| 1055 | onContextMenu={(event) => { |
| 1056 | event.preventDefault(); |
| 1057 | void handleProjectContextMenu(project.id, { |
| 1058 | x: event.clientX, |
| 1059 | y: event.clientY, |
| 1060 | }); |
| 1061 | }} |
| 1062 | > |
| 1063 | <ChevronRightIcon |
| 1064 | className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${ |
| 1065 | project.expanded ? "rotate-90" : "" |
| 1066 | }`} |
| 1038 | 1067 | /> |
| 1039 | | } |
| 1040 | | onContextMenu={(event) => { |
| 1041 | | event.preventDefault(); |
| 1042 | | void handleProjectContextMenu(project.id, { |
| 1043 | | x: event.clientX, |
| 1044 | | y: event.clientY, |
| 1045 | | }); |
| 1046 | | }} |
| 1047 | | > |
| 1048 | | <ChevronRightIcon |
| 1049 | | className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${ |
| 1050 | | project.expanded ? "rotate-90" : "" |
| 1051 | | }`} |
| 1052 | | /> |
| 1053 | | <ProjectFavicon cwd={project.cwd} /> |
| 1054 | | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> |
| 1055 | | {project.name} |
| 1056 | | </span> |
| 1057 | | <span className="text-[10px] text-muted-foreground/60"> |
| 1058 | | {visibleThreads.length} |
| 1059 | | </span> |
| 1060 | | </CollapsibleTrigger> |
| 1068 | <ProjectFavicon cwd={project.cwd} /> |
| 1069 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> |
| 1070 | {project.name} |
| 1071 | </span> |
| 1072 | </CollapsibleTrigger> |
| 1073 | <Tooltip> |
| 1074 | <TooltipTrigger |
| 1075 | render={ |
| 1076 | <SidebarMenuAction |
| 1077 | render={ |
| 1078 | <button |
| 1079 | type="button" |
| 1080 | aria-label={`Create new thread in ${project.name}`} |
| 1081 | /> |
| 1082 | } |
| 1083 | showOnHover |
| 1084 | className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" |
| 1085 | onClick={(event) => { |
| 1086 | event.preventDefault(); |
| 1087 | event.stopPropagation(); |
| 1088 | void handleNewThread(project.id); |
| 1089 | }} |
| 1090 | > |
| 1091 | <SquarePenIcon className="size-3.5" /> |
| 1092 | </SidebarMenuAction> |
| 1093 | } |
| 1094 | /> |
| 1095 | <TooltipPopup side="top"> |
| 1096 | {newThreadShortcutLabel |
| 1097 | ? `New thread (${newThreadShortcutLabel})` |
| 1098 | : "New thread"} |
| 1099 | </TooltipPopup> |
| 1100 | </Tooltip> |
| 1101 | </div> |
| 1061 | 1102 | |
| 1062 | 1103 | <CollapsibleContent> |
| 1063 | 1104 | <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0"> |
| ... | ... | @@ -1201,19 +1242,34 @@ export default function Sidebar() { |
| 1201 | 1242 | ); |
| 1202 | 1243 | })} |
| 1203 | 1244 | |
| 1204 | | <SidebarMenuSubItem className="w-full"> |
| 1205 | | <SidebarMenuSubButton |
| 1206 | | render={<button type="button" />} |
| 1207 | | size="sm" |
| 1208 | | className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" |
| 1209 | | onClick={() => { |
| 1210 | | void handleNewThread(project.id); |
| 1211 | | }} |
| 1212 | | > |
| 1213 | | <span>+</span> |
| 1214 | | <span>New thread</span> |
| 1215 | | </SidebarMenuSubButton> |
| 1216 | | </SidebarMenuSubItem> |
| 1245 | {hasHiddenThreads && !isThreadListExpanded && ( |
| 1246 | <SidebarMenuSubItem className="w-full"> |
| 1247 | <SidebarMenuSubButton |
| 1248 | render={<button type="button" />} |
| 1249 | size="sm" |
| 1250 | className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" |
| 1251 | onClick={() => { |
| 1252 | expandThreadListForProject(project.id); |
| 1253 | }} |
| 1254 | > |
| 1255 | <span>Show more</span> |
| 1256 | </SidebarMenuSubButton> |
| 1257 | </SidebarMenuSubItem> |
| 1258 | )} |
| 1259 | {hasHiddenThreads && isThreadListExpanded && ( |
| 1260 | <SidebarMenuSubItem className="w-full"> |
| 1261 | <SidebarMenuSubButton |
| 1262 | render={<button type="button" />} |
| 1263 | size="sm" |
| 1264 | className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" |
| 1265 | onClick={() => { |
| 1266 | collapseThreadListForProject(project.id); |
| 1267 | }} |
| 1268 | > |
| 1269 | <span>Show less</span> |
| 1270 | </SidebarMenuSubButton> |
| 1271 | </SidebarMenuSubItem> |
| 1272 | )} |
| 1217 | 1273 | </SidebarMenuSub> |
| 1218 | 1274 | </CollapsibleContent> |
| 1219 | 1275 | </SidebarMenuItem> |