diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index abc33065b0c539d8e2bbaa5f9fbebff8f02f88c5..018e89d14b64913fb2279be3da7219bf38fe8482 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { FolderIcon, GitPullRequestIcon, RocketIcon, + SquarePenIcon, TerminalIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -21,7 +22,7 @@ import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; import { useStore } from "../store"; -import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; +import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; import { type Thread } from "../types"; import { derivePendingApprovals } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; @@ -46,6 +47,7 @@ import { SidebarFooter, SidebarGroup, SidebarHeader, + SidebarMenuAction, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -59,6 +61,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { isNonEmpty as isNonEmptyString } from "effect/String"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; +const THREAD_PREVIEW_LIMIT = 6; async function copyTextToClipboard(text: string): Promise { if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) { @@ -291,6 +294,9 @@ export default function Sidebar() { const [isAddingProject, setIsAddingProject] = useState(false); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); + const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< + ReadonlySet + >(() => new Set()); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const [desktopUpdateState, setDesktopUpdateState] = useState(null); @@ -857,15 +863,6 @@ export default function Sidebar() { }; }, []); - const onCreateThreadClick = () => { - if (projects.length === 0) { - setAddingProject(true); - return; - } - const firstProject = projects[0]; - if (firstProject) void handleNewThread(firstProject.id); - }; - const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); const desktopUpdateTooltip = desktopUpdateState @@ -887,6 +884,12 @@ export default function Sidebar() { : shouldHighlightDesktopUpdateError(desktopUpdateState) ? "text-rose-500 animate-pulse" : "text-amber-500 animate-pulse"; + const newThreadShortcutLabel = useMemo( + () => + shortcutLabelForCommand(keybindings, "chat.newLocal") ?? + shortcutLabelForCommand(keybindings, "chat.new"), + [keybindings], + ); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -946,6 +949,24 @@ export default function Sidebar() { } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); + const expandThreadListForProject = useCallback((projectId: ProjectId) => { + setExpandedThreadListsByProject((current) => { + if (current.has(projectId)) return current; + const next = new Set(current); + next.add(projectId); + return next; + }); + }, []); + + const collapseThreadListForProject = useCallback((projectId: ProjectId) => { + setExpandedThreadListsByProject((current) => { + if (!current.has(projectId)) return current; + const next = new Set(current); + next.delete(projectId); + return next; + }); + }, []); + const wordmark = (
@@ -961,17 +982,6 @@ export default function Sidebar() {
); - const newThreadButton = ( - - ); - return ( <> {isElectron ? ( @@ -998,12 +1008,10 @@ export default function Sidebar() { )} -
{newThreadButton}
) : ( {wordmark} - {newThreadButton} )} @@ -1011,13 +1019,19 @@ export default function Sidebar() { {projects.map((project) => { - const visibleThreads = threads + const projectThreads = threads .filter((thread) => thread.projectId === project.id) .toSorted((a, b) => { const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); if (byDate !== 0) return byDate; return b.id.localeCompare(a.id); }); + const isThreadListExpanded = expandedThreadListsByProject.has(project.id); + const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const visibleThreads = + hasHiddenThreads && !isThreadListExpanded + ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) + : projectThreads; return ( - + + } + onContextMenu={(event) => { + event.preventDefault(); + void handleProjectContextMenu(project.id, { + x: event.clientX, + y: event.clientY, + }); + }} + > + - } - onContextMenu={(event) => { - event.preventDefault(); - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - - - - {project.name} - - - {visibleThreads.length} - - + + + {project.name} + + + + + } + showOnHover + className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(project.id); + }} + > + + + } + /> + + {newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"} + + + @@ -1201,19 +1242,34 @@ export default function Sidebar() { ); })} - - } - size="sm" - 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" - onClick={() => { - void handleNewThread(project.id); - }} - > - + - New thread - - + {hasHiddenThreads && !isThreadListExpanded && ( + + } + size="sm" + 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" + onClick={() => { + expandThreadListForProject(project.id); + }} + > + Show more + + + )} + {hasHiddenThreads && isThreadListExpanded && ( + + } + size="sm" + 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" + onClick={() => { + collapseThreadListForProject(project.id); + }} + > + Show less + + + )}