authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 15:16:10-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 15:16:10-08:00
log2d9139767a131d7e0165e36facf2c71d01f13e63
tree4b885d34996bc1370a972a4b41f40fd40d077055
parent95fcc89074d44518cded3adfa08dc18284c87fbe

Refine sidebar thread actions and list preview

- Move "New thread" action to each project header with hover icon + shortcut tooltip - Limit thread lists to 6 items by default with per-project Show more/Show less controls - Remove the global sidebar-level new thread button

1 files changed, 120 insertions(+), 64 deletions(-)

apps/web/src/components/Sidebar.tsx+120-64
......@@ -3,6 +3,7 @@ import {
33 FolderIcon,
44 GitPullRequestIcon,
55 RocketIcon,
6 SquarePenIcon,
67 TerminalIcon,
78} from "lucide-react";
89import { useCallback, useEffect, useMemo, useRef, useState } from "react";
......@@ -21,7 +22,7 @@ import { isElectron } from "../env";
2122import { APP_STAGE_LABEL } from "../branding";
2223import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
2324import { useStore } from "../store";
24import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
25import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";
2526import { type Thread } from "../types";
2627import { derivePendingApprovals } from "../session-logic";
2728import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
......@@ -46,6 +47,7 @@ import {
4647 SidebarFooter,
4748 SidebarGroup,
4849 SidebarHeader,
50 SidebarMenuAction,
4951 SidebarMenu,
5052 SidebarMenuButton,
5153 SidebarMenuItem,
......@@ -59,6 +61,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "
5961import { isNonEmpty as isNonEmptyString } from "effect/String";
6062
6163const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
64const THREAD_PREVIEW_LIMIT = 6;
6265
6366async function copyTextToClipboard(text: string): Promise<void> {
6467 if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) {
......@@ -291,6 +294,9 @@ export default function Sidebar() {
291294 const [isAddingProject, setIsAddingProject] = useState(false);
292295 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
293296 const [renamingTitle, setRenamingTitle] = useState("");
297 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
298 ReadonlySet<ProjectId>
299 >(() => new Set());
294300 const renamingCommittedRef = useRef(false);
295301 const renamingInputRef = useRef<HTMLInputElement | null>(null);
296302 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
......@@ -857,15 +863,6 @@ export default function Sidebar() {
857863 };
858864 }, []);
859865
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
869866 const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState);
870867
871868 const desktopUpdateTooltip = desktopUpdateState
......@@ -887,6 +884,12 @@ export default function Sidebar() {
887884 : shouldHighlightDesktopUpdateError(desktopUpdateState)
888885 ? "text-rose-500 animate-pulse"
889886 : "text-amber-500 animate-pulse";
887 const newThreadShortcutLabel = useMemo(
888 () =>
889 shortcutLabelForCommand(keybindings, "chat.newLocal") ??
890 shortcutLabelForCommand(keybindings, "chat.new"),
891 [keybindings],
892 );
890893
891894 const handleDesktopUpdateButtonClick = useCallback(() => {
892895 const bridge = window.desktopBridge;
......@@ -946,6 +949,24 @@ export default function Sidebar() {
946949 }
947950 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);
948951
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
949970 const wordmark = (
950971 <div className="flex items-center gap-2">
951972 <SidebarTrigger className="shrink-0 md:hidden" />
......@@ -961,17 +982,6 @@ export default function Sidebar() {
961982 </div>
962983 );
963984
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
975985 return (
976986 <>
977987 {isElectron ? (
......@@ -998,12 +1008,10 @@ export default function Sidebar() {
9981008 </Tooltip>
9991009 )}
10001010 </SidebarHeader>
1001 <div className="px-4 py-2">{newThreadButton}</div>
10021011 </>
10031012 ) : (
10041013 <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">
10051014 {wordmark}
1006 {newThreadButton}
10071015 </SidebarHeader>
10081016 )}
10091017
......@@ -1011,13 +1019,19 @@ export default function Sidebar() {
10111019 <SidebarGroup className="px-2 py-2">
10121020 <SidebarMenu>
10131021 {projects.map((project) => {
1014 const visibleThreads = threads
1022 const projectThreads = threads
10151023 .filter((thread) => thread.projectId === project.id)
10161024 .toSorted((a, b) => {
10171025 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
10181026 if (byDate !== 0) return byDate;
10191027 return b.id.localeCompare(a.id);
10201028 });
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;
10211035
10221036 return (
10231037 <Collapsible
......@@ -1030,34 +1044,61 @@ export default function Sidebar() {
10301044 }}
10311045 >
10321046 <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 }`}
10381067 />
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>
10611102
10621103 <CollapsibleContent>
10631104 <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() {
12011242 );
12021243 })}
12031244
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 )}
12171273 </SidebarMenuSub>
12181274 </CollapsibleContent>
12191275 </SidebarMenuItem>