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 {...@@ -3,6 +3,7 @@ import {
3 FolderIcon,3 FolderIcon,
4 GitPullRequestIcon,4 GitPullRequestIcon,
5 RocketIcon,5 RocketIcon,
6 SquarePenIcon,
6 TerminalIcon,7 TerminalIcon,
7} from "lucide-react";8} from "lucide-react";
8import { useCallback, useEffect, useMemo, useRef, useState } from "react";9import { useCallback, useEffect, useMemo, useRef, useState } from "react";
...@@ -21,7 +22,7 @@ import { isElectron } from "../env";...@@ -21,7 +22,7 @@ import { isElectron } from "../env";
21import { APP_STAGE_LABEL } from "../branding";22import { APP_STAGE_LABEL } from "../branding";
22import { newCommandId, newProjectId, newThreadId } from "../lib/utils";23import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
23import { useStore } from "../store";24import { useStore } from "../store";
24import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";25import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";
25import { type Thread } from "../types";26import { type Thread } from "../types";
26import { derivePendingApprovals } from "../session-logic";27import { derivePendingApprovals } from "../session-logic";
27import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";28import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
...@@ -46,6 +47,7 @@ import {...@@ -46,6 +47,7 @@ import {
46 SidebarFooter,47 SidebarFooter,
47 SidebarGroup,48 SidebarGroup,
48 SidebarHeader,49 SidebarHeader,
50 SidebarMenuAction,
49 SidebarMenu,51 SidebarMenu,
50 SidebarMenuButton,52 SidebarMenuButton,
51 SidebarMenuItem,53 SidebarMenuItem,
...@@ -59,6 +61,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "...@@ -59,6 +61,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "
59import { isNonEmpty as isNonEmptyString } from "effect/String";61import { isNonEmpty as isNonEmptyString } from "effect/String";
6062
61const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];63const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
64const THREAD_PREVIEW_LIMIT = 6;
6265
63async function copyTextToClipboard(text: string): Promise<void> {66async function copyTextToClipboard(text: string): Promise<void> {
64 if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) {67 if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) {
...@@ -291,6 +294,9 @@ export default function Sidebar() {...@@ -291,6 +294,9 @@ export default function Sidebar() {
291 const [isAddingProject, setIsAddingProject] = useState(false);294 const [isAddingProject, setIsAddingProject] = useState(false);
292 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);295 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
293 const [renamingTitle, setRenamingTitle] = useState("");296 const [renamingTitle, setRenamingTitle] = useState("");
297 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
298 ReadonlySet<ProjectId>
299 >(() => new Set());
294 const renamingCommittedRef = useRef(false);300 const renamingCommittedRef = useRef(false);
295 const renamingInputRef = useRef<HTMLInputElement | null>(null);301 const renamingInputRef = useRef<HTMLInputElement | null>(null);
296 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);302 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
...@@ -857,15 +863,6 @@ export default function Sidebar() {...@@ -857,15 +863,6 @@ export default function Sidebar() {
857 };863 };
858 }, []);864 }, []);
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
869 const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState);866 const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState);
870867
871 const desktopUpdateTooltip = desktopUpdateState868 const desktopUpdateTooltip = desktopUpdateState
...@@ -887,6 +884,12 @@ export default function Sidebar() {...@@ -887,6 +884,12 @@ export default function Sidebar() {
887 : shouldHighlightDesktopUpdateError(desktopUpdateState)884 : shouldHighlightDesktopUpdateError(desktopUpdateState)
888 ? "text-rose-500 animate-pulse"885 ? "text-rose-500 animate-pulse"
889 : "text-amber-500 animate-pulse";886 : "text-amber-500 animate-pulse";
887 const newThreadShortcutLabel = useMemo(
888 () =>
889 shortcutLabelForCommand(keybindings, "chat.newLocal") ??
890 shortcutLabelForCommand(keybindings, "chat.new"),
891 [keybindings],
892 );
890893
891 const handleDesktopUpdateButtonClick = useCallback(() => {894 const handleDesktopUpdateButtonClick = useCallback(() => {
892 const bridge = window.desktopBridge;895 const bridge = window.desktopBridge;
...@@ -946,6 +949,24 @@ export default function Sidebar() {...@@ -946,6 +949,24 @@ export default function Sidebar() {
946 }949 }
947 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);950 }, [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
949 const wordmark = (970 const wordmark = (
950 <div className="flex items-center gap-2">971 <div className="flex items-center gap-2">
951 <SidebarTrigger className="shrink-0 md:hidden" />972 <SidebarTrigger className="shrink-0 md:hidden" />
...@@ -961,17 +982,6 @@ export default function Sidebar() {...@@ -961,17 +982,6 @@ export default function Sidebar() {
961 </div>982 </div>
962 );983 );
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
975 return (985 return (
976 <>986 <>
977 {isElectron ? (987 {isElectron ? (
...@@ -998,12 +1008,10 @@ export default function Sidebar() {...@@ -998,12 +1008,10 @@ export default function Sidebar() {
998 </Tooltip>1008 </Tooltip>
999 )}1009 )}
1000 </SidebarHeader>1010 </SidebarHeader>
1001 <div className="px-4 py-2">{newThreadButton}</div>
1002 </>1011 </>
1003 ) : (1012 ) : (
1004 <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">1013 <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">
1005 {wordmark}1014 {wordmark}
1006 {newThreadButton}
1007 </SidebarHeader>1015 </SidebarHeader>
1008 )}1016 )}
10091017
...@@ -1011,13 +1019,19 @@ export default function Sidebar() {...@@ -1011,13 +1019,19 @@ export default function Sidebar() {
1011 <SidebarGroup className="px-2 py-2">1019 <SidebarGroup className="px-2 py-2">
1012 <SidebarMenu>1020 <SidebarMenu>
1013 {projects.map((project) => {1021 {projects.map((project) => {
1014 const visibleThreads = threads1022 const projectThreads = threads
1015 .filter((thread) => thread.projectId === project.id)1023 .filter((thread) => thread.projectId === project.id)
1016 .toSorted((a, b) => {1024 .toSorted((a, b) => {
1017 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();1025 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
1018 if (byDate !== 0) return byDate;1026 if (byDate !== 0) return byDate;
1019 return b.id.localeCompare(a.id);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;
10211035
1022 return (1036 return (
1023 <Collapsible1037 <Collapsible
...@@ -1030,34 +1044,61 @@ export default function Sidebar() {...@@ -1030,34 +1044,61 @@ export default function Sidebar() {
1030 }}1044 }}
1031 >1045 >
1032 <SidebarMenuItem>1046 <SidebarMenuItem>
1033 <CollapsibleTrigger1047 <div className="group/project-header relative">
1034 render={1048 <CollapsibleTrigger
1035 <SidebarMenuButton1049 render={
1036 size="sm"1050 <SidebarMenuButton
1037 className="gap-2 px-2 py-1.5 text-left hover:bg-accent"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 }1068 <ProjectFavicon cwd={project.cwd} />
1040 onContextMenu={(event) => {1069 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1041 event.preventDefault();1070 {project.name}
1042 void handleProjectContextMenu(project.id, {1071 </span>
1043 x: event.clientX,1072 </CollapsibleTrigger>
1044 y: event.clientY,1073 <Tooltip>
1045 });1074 <TooltipTrigger
1046 }}1075 render={
1047 >1076 <SidebarMenuAction
1048 <ChevronRightIcon1077 render={
1049 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${1078 <button
1050 project.expanded ? "rotate-90" : ""1079 type="button"
1051 }`}1080 aria-label={`Create new thread in ${project.name}`}
1052 />1081 />
1053 <ProjectFavicon cwd={project.cwd} />1082 }
1054 <span className="flex-1 truncate text-xs font-medium text-foreground/90">1083 showOnHover
1055 {project.name}1084 className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1056 </span>1085 onClick={(event) => {
1057 <span className="text-[10px] text-muted-foreground/60">1086 event.preventDefault();
1058 {visibleThreads.length}1087 event.stopPropagation();
1059 </span>1088 void handleNewThread(project.id);
1060 </CollapsibleTrigger>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
1062 <CollapsibleContent>1103 <CollapsibleContent>
1063 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">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,19 +1242,34 @@ export default function Sidebar() {
1201 );1242 );
1202 })}1243 })}
12031244
1204 <SidebarMenuSubItem className="w-full">1245 {hasHiddenThreads && !isThreadListExpanded && (
1205 <SidebarMenuSubButton1246 <SidebarMenuSubItem className="w-full">
1206 render={<button type="button" />}1247 <SidebarMenuSubButton
1207 size="sm"1248 render={<button type="button" />}
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"1249 size="sm"
1209 onClick={() => {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"
1210 void handleNewThread(project.id);1251 onClick={() => {
1211 }}1252 expandThreadListForProject(project.id);
1212 >1253 }}
1213 <span>+</span>1254 >
1214 <span>New thread</span>1255 <span>Show more</span>
1215 </SidebarMenuSubButton>1256 </SidebarMenuSubButton>
1216 </SidebarMenuSubItem>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 </SidebarMenuSub>1273 </SidebarMenuSub>
1218 </CollapsibleContent>1274 </CollapsibleContent>
1219 </SidebarMenuItem>1275 </SidebarMenuItem>