diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 018e89d14b64..09b9c6ec2689 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,11 +2,19 @@ import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, + GripVerticalIcon, RocketIcon, SquarePenIcon, TerminalIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { DEFAULT_MODEL, type DesktopUpdateState, @@ -15,21 +23,39 @@ import { type GitStatusResult, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; -import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; import { useStore } from "../store"; -import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; +import { + isChatNewLocalShortcut, + isChatNewShortcut, + shortcutLabelForCommand, +} from "../keybindings"; import { type Thread } from "../types"; import { derivePendingApprovals } from "../session-logic"; -import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; +import { + gitRemoveWorktreeMutationOptions, + gitStatusQueryOptions, +} from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; -import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { + type DraftThreadEnvMode, + useComposerDraftStore, +} from "../composerDraftStore"; +import { + selectThreadTerminalState, + useTerminalStateStore, +} from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { getDesktopUpdateActionError, @@ -40,7 +66,11 @@ import { shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "./ui/collapsible"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -57,14 +87,20 @@ import { SidebarSeparator, SidebarTrigger, } from "./ui/sidebar"; -import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; +import { + formatWorktreePathForDisplay, + getOrphanedWorktreePathForThread, +} from "../worktreeCleanup"; 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) { + if ( + typeof navigator === "undefined" || + navigator.clipboard?.writeText === undefined + ) { throw new Error("Clipboard API unavailable."); } await navigator.clipboard.writeText(text); @@ -113,7 +149,10 @@ function hasUnseenCompletion(thread: Thread): boolean { return completedAt > lastVisitedAt; } -function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null { +function threadStatusPill( + thread: Thread, + hasPendingApprovals: boolean, +): ThreadStatusPill | null { if (hasPendingApprovals) { return { label: "Pending Approval", @@ -237,12 +276,16 @@ function getServerHttpOrigin(): string { const serverHttpOrigin = getServerHttpOrigin(); function ProjectFavicon({ cwd }: { cwd: string }) { - const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); + const [status, setStatus] = useState<"loading" | "loaded" | "error">( + "loading", + ); const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`; if (status === "error") { - return ; + return ( + + ); } return ( @@ -261,15 +304,26 @@ export default function Sidebar() { const threads = useStore((store) => store.threads); const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); - const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); + const reorderProjects = useStore((store) => store.reorderProjects); + const clearComposerDraftForThread = useComposerDraftStore( + (store) => store.clearThreadDraft, + ); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); - const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const terminalStateByThreadId = useTerminalStateStore( + (state) => state.terminalStateByThreadId, + ); + const clearTerminalState = useTerminalStateStore( + (state) => state.clearTerminalState, + ); + const setProjectDraftThreadId = useComposerDraftStore( + (store) => store.setProjectDraftThreadId, + ); + const setDraftThreadContext = useComposerDraftStore( + (store) => store.setDraftThreadContext, + ); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); @@ -280,26 +334,36 @@ export default function Sidebar() { const { settings: appSettings } = useAppSettings(); const routeThreadId = useParams({ strict: false, - select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + select: (params) => + params.threadId ? ThreadId.makeUnsafe(params.threadId) : null, }); const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ ...serverConfigQueryOptions(), select: (config) => config.keybindings, }); const queryClient = useQueryClient(); - const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); + const removeWorktreeMutation = useMutation( + gitRemoveWorktreeMutationOptions({ queryClient }), + ); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); - const [renamingThreadId, setRenamingThreadId] = useState(null); + const [renamingThreadId, setRenamingThreadId] = useState( + null, + ); const [renamingTitle, setRenamingTitle] = useState(""); - const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< - ReadonlySet - >(() => new Set()); + const [expandedThreadListsByProject, setExpandedThreadListsByProject] = + useState>(() => new Set()); + const [draggedProjectId, setDraggedProjectId] = useState( + null, + ); + const [projectDropIndex, setProjectDropIndex] = useState(null); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); - const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const projectBlockRefs = useRef(new Map()); + const [desktopUpdateState, setDesktopUpdateState] = + useState(null); const pendingApprovalByThreadId = useMemo(() => { const map = new Map(); for (const thread of threads) { @@ -308,7 +372,8 @@ export default function Sidebar() { return map; }, [threads]); const projectCwdById = useMemo( - () => new Map(projects.map((project) => [project.id, project.cwd] as const)), + () => + new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); const threadGitTargets = useMemo( @@ -316,7 +381,8 @@ export default function Sidebar() { threads.map((thread) => ({ threadId: thread.id, branch: thread.branch, - cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, + cwd: + thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, })), [projectCwdById, threads], ); @@ -353,33 +419,39 @@ export default function Sidebar() { for (const target of threadGitTargets) { const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; const branchMatches = - target.branch !== null && status?.branch !== null && status?.branch === target.branch; + target.branch !== null && + status?.branch !== null && + status?.branch === target.branch; map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); } return map; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); - const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { - event.preventDefault(); - event.stopPropagation(); + const openPrLink = useCallback( + (event: React.MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); - const api = readNativeApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Link opening is unavailable.", - }); - return; - } + const api = readNativeApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Link opening is unavailable.", + }); + return; + } - void api.shell.openExternal(prUrl).catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open PR link", - description: error instanceof Error ? error.message : "An error occurred.", + void api.shell.openExternal(prUrl).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open PR link", + description: + error instanceof Error ? error.message : "An error occurred.", + }); }); - }); - }, []); + }, + [], + ); const handleNewThread = useCallback( ( @@ -399,7 +471,9 @@ export default function Sidebar() { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { setDraftThreadContext(storedDraftThread.threadId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasWorktreePathOption + ? { worktreePath: options?.worktreePath ?? null } + : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } @@ -415,12 +489,20 @@ export default function Sidebar() { } clearProjectDraftThreadId(projectId); - const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; - if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { + const activeDraftThread = routeThreadId + ? getDraftThread(routeThreadId) + : null; + if ( + activeDraftThread && + routeThreadId && + activeDraftThread.projectId === projectId + ) { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { setDraftThreadContext(routeThreadId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasWorktreePathOption + ? { worktreePath: options?.worktreePath ?? null } + : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } @@ -459,7 +541,8 @@ export default function Sidebar() { const latestThread = threads .filter((thread) => thread.projectId === projectId) .toSorted((a, b) => { - const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + const byDate = + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); if (byDate !== 0) return byDate; return b.id.localeCompare(a.id); })[0]; @@ -514,7 +597,12 @@ export default function Sidebar() { } finishAddingProject(); }, - [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects], + [ + focusMostRecentThreadForProject, + handleNewThread, + isAddingProject, + projects, + ], ); const handleAddProject = () => { @@ -554,7 +642,10 @@ export default function Sidebar() { const trimmed = newTitle.trim(); if (trimmed.length === 0) { - toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + toastManager.add({ + type: "warning", + title: "Thread title cannot be empty", + }); finishRename(); return; } @@ -578,7 +669,8 @@ export default function Sidebar() { toastManager.add({ type: "error", title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", + description: + error instanceof Error ? error.message : "An error occurred.", }); } finishRename(); @@ -625,7 +717,8 @@ export default function Sidebar() { toastManager.add({ type: "error", title: "Failed to copy thread ID", - description: error instanceof Error ? error.message : "An error occurred.", + description: + error instanceof Error ? error.message : "An error occurred.", }); } return; @@ -642,12 +735,18 @@ export default function Sidebar() { return; } } - const threadProject = projects.find((project) => project.id === thread.projectId); - const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadId); + const threadProject = projects.find( + (project) => project.id === thread.projectId, + ); + const orphanedWorktreePath = getOrphanedWorktreePathForThread( + threads, + threadId, + ); const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const canDeleteWorktree = + orphanedWorktreePath !== null && threadProject !== undefined; const shouldDeleteWorktree = canDeleteWorktree && (await api.dialogs.confirm( @@ -680,7 +779,8 @@ export default function Sidebar() { } const shouldNavigateToFallback = routeThreadId === threadId; - const fallbackThreadId = threads.find((entry) => entry.id !== threadId)?.id ?? null; + const fallbackThreadId = + threads.find((entry) => entry.id !== threadId)?.id ?? null; await api.orchestration.dispatchCommand({ type: "thread.delete", commandId: newCommandId(), @@ -712,13 +812,19 @@ export default function Sidebar() { force: true, }); } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error removing worktree."; - console.error("Failed to remove orphaned worktree after thread deletion", { - threadId, - projectCwd: threadProject.cwd, - worktreePath: orphanedWorktreePath, - error, - }); + const message = + error instanceof Error + ? error.message + : "Unknown error removing worktree."; + console.error( + "Failed to remove orphaned worktree after thread deletion", + { + threadId, + projectCwd: threadProject.cwd, + worktreePath: orphanedWorktreePath, + error, + }, + ); toastManager.add({ type: "error", title: "Thread deleted, but worktree removal failed", @@ -753,7 +859,9 @@ export default function Sidebar() { const project = projects.find((entry) => entry.id === projectId); if (!project) return; - const projectThreads = threads.filter((thread) => thread.projectId === projectId); + const projectThreads = threads.filter( + (thread) => thread.projectId === projectId, + ); if (projectThreads.length > 0) { toastManager.add({ type: "warning", @@ -764,7 +872,10 @@ export default function Sidebar() { } const confirmed = await api.dialogs.confirm( - [`Delete project "${project.name}"?`, "This action cannot be undone."].join("\n"), + [ + `Delete project "${project.name}"?`, + "This action cannot be undone.", + ].join("\n"), ); if (!confirmed) return; @@ -780,7 +891,10 @@ export default function Sidebar() { projectId, }); } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error deleting project."; + const message = + error instanceof Error + ? error.message + : "Unknown error deleting project."; console.error("Failed to remove project", { projectId, error }); toastManager.add({ type: "error", @@ -803,10 +917,14 @@ export default function Sidebar() { const activeThread = routeThreadId ? threads.find((thread) => thread.id === routeThreadId) : undefined; - const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; + const activeDraftThread = routeThreadId + ? getDraftThread(routeThreadId) + : null; if (isChatNewLocalShortcut(event, keybindings)) { const projectId = - activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; + activeThread?.projectId ?? + activeDraftThread?.projectId ?? + projects[0]?.id; if (!projectId) return; event.preventDefault(); void handleNewThread(projectId); @@ -814,13 +932,19 @@ export default function Sidebar() { } if (!isChatNewShortcut(event, keybindings)) return; - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; + const projectId = + activeThread?.projectId ?? + activeDraftThread?.projectId ?? + projects[0]?.id; if (!projectId) return; event.preventDefault(); void handleNewThread(projectId, { branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, - worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, - envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), + worktreePath: + activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, + envMode: + activeDraftThread?.envMode ?? + (activeThread?.worktreePath ? "worktree" : "local"), }); }; @@ -828,7 +952,14 @@ export default function Sidebar() { return () => { window.removeEventListener("keydown", onWindowKeyDown); }; - }, [getDraftThread, handleNewThread, keybindings, projects, routeThreadId, threads]); + }, [ + getDraftThread, + handleNewThread, + keybindings, + projects, + routeThreadId, + threads, + ]); useEffect(() => { if (!isElectron) return; @@ -863,13 +994,15 @@ export default function Sidebar() { }; }, []); - const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); + const showDesktopUpdateButton = + isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); const desktopUpdateTooltip = desktopUpdateState ? getDesktopUpdateButtonTooltip(desktopUpdateState) : "Update available"; - const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); + const desktopUpdateButtonDisabled = + isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState ? resolveDesktopUpdateButtonAction(desktopUpdateState) : "none"; @@ -883,7 +1016,7 @@ export default function Sidebar() { ? "text-sky-400" : shouldHighlightDesktopUpdateError(desktopUpdateState) ? "text-rose-500 animate-pulse" - : "text-amber-500 animate-pulse"; + : "text-amber-500 animate-pulse"; const newThreadShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "chat.newLocal") ?? @@ -894,7 +1027,8 @@ export default function Sidebar() { const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; - if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; + if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") + return; if (desktopUpdateButtonAction === "download") { void bridge @@ -904,7 +1038,8 @@ export default function Sidebar() { toastManager.add({ type: "success", title: "Update downloaded", - description: "Restart the app from the update button to install it.", + description: + "Restart the app from the update button to install it.", }); } if (!shouldToastDesktopUpdateActionResult(result)) return; @@ -920,7 +1055,10 @@ export default function Sidebar() { toastManager.add({ type: "error", title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", + description: + error instanceof Error + ? error.message + : "An unexpected error occurred.", }); }); return; @@ -943,11 +1081,18 @@ export default function Sidebar() { toastManager.add({ type: "error", title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", + description: + error instanceof Error + ? error.message + : "An unexpected error occurred.", }); }); } - }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); + }, [ + desktopUpdateButtonAction, + desktopUpdateButtonDisabled, + desktopUpdateState, + ]); const expandThreadListForProject = useCallback((projectId: ProjectId) => { setExpandedThreadListsByProject((current) => { @@ -982,6 +1127,117 @@ export default function Sidebar() { ); + const resetProjectDrag = () => { + setDraggedProjectId(null); + setProjectDropIndex(null); + }; + + const setProjectBlockRef = ( + projectId: ProjectId, + node: HTMLDivElement | null, + ) => { + if (node) { + projectBlockRefs.current.set(projectId, node); + return; + } + projectBlockRefs.current.delete(projectId); + }; + + const resolveProjectDropIndex = ( + sourceProjectId: ProjectId, + targetProjectIndex: number, + ) => { + const sourceProjectIndex = projects.findIndex( + (project) => project.id === sourceProjectId, + ); + if ( + sourceProjectIndex === -1 || + sourceProjectIndex === targetProjectIndex + ) { + return null; + } + return sourceProjectIndex < targetProjectIndex + ? targetProjectIndex + 1 + : targetProjectIndex; + }; + + const resolveProjectDropIndexFromPointer = ( + sourceProjectId: ProjectId, + clientY: number, + ) => { + const blockEntries = projects + .map((project, index) => { + const element = projectBlockRefs.current.get(project.id); + if (!element) return null; + const bounds = element.getBoundingClientRect(); + return { + bounds, + index, + }; + }) + .filter((entry) => entry !== null); + if (blockEntries.length === 0) return null; + for (const entry of blockEntries) { + if (clientY < entry.bounds.top || clientY > entry.bounds.bottom) continue; + return resolveProjectDropIndex(sourceProjectId, entry.index); + } + const nearestEntry = blockEntries.toSorted((left, right) => { + const leftDistance = Math.abs( + (left.bounds.top + left.bounds.bottom) / 2 - clientY, + ); + const rightDistance = Math.abs( + (right.bounds.top + right.bounds.bottom) / 2 - clientY, + ); + return leftDistance - rightDistance; + })[0]; + if (!nearestEntry) return null; + return resolveProjectDropIndex(sourceProjectId, nearestEntry.index); + }; + + const startProjectDrag = ( + event: React.MouseEvent, + projectId: ProjectId, + ) => { + event.preventDefault(); + event.stopPropagation(); + setDraggedProjectId(projectId); + setProjectDropIndex(null); + const previousUserSelect = document.body.style.userSelect; + document.body.style.userSelect = "none"; + const updateDropIndex = (clientY: number) => { + setProjectDropIndex( + resolveProjectDropIndexFromPointer(projectId, clientY), + ); + }; + const stopDragging = (clientY: number | null) => { + document.body.style.userSelect = previousUserSelect; + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener("blur", handleWindowBlur); + const destinationIndex = + clientY === null + ? null + : resolveProjectDropIndexFromPointer(projectId, clientY); + if (destinationIndex !== null) { + reorderProjects(projectId, destinationIndex); + } + resetProjectDrag(); + }; + const handleMouseMove = (moveEvent: MouseEvent) => { + updateDropIndex(moveEvent.clientY); + }; + const handleMouseUp = (upEvent: MouseEvent) => { + stopDragging(upEvent.clientY); + }; + const handleWindowBlur = () => { + stopDragging(null); + }; + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + window.addEventListener("blur", handleWindowBlur); + updateDropIndex(event.clientY); + }; + return ( <> {isElectron ? ( @@ -1004,7 +1260,9 @@ export default function Sidebar() { } /> - {desktopUpdateTooltip} + + {desktopUpdateTooltip} + )} @@ -1018,262 +1276,358 @@ export default function Sidebar() { - {projects.map((project) => { + {projects.map((project, projectIndex) => { const projectThreads = threads .filter((thread) => thread.projectId === project.id) .toSorted((a, b) => { - const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + 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 isThreadListExpanded = expandedThreadListsByProject.has( + project.id, + ); + const hasHiddenThreads = + projectThreads.length > THREAD_PREVIEW_LIMIT; const visibleThreads = hasHiddenThreads && !isThreadListExpanded ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) : projectThreads; + const resolvedProjectDropIndex = + draggedProjectId === null + ? null + : resolveProjectDropIndex(draggedProjectId, projectIndex); + const isProjectDropTarget = + draggedProjectId !== null && + projectDropIndex !== null && + resolvedProjectDropIndex === projectDropIndex; return ( - { - if (open === project.expanded) return; - toggleProject(project.id); - }} - > - -
- - } - onContextMenu={(event) => { - event.preventDefault(); - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); + + {draggedProjectId !== null && + projectDropIndex === projectIndex && ( + +
+ + )} + { + if (open === project.expanded) return; + toggleProject(project.id); + }} + > + +
{ + setProjectBlockRef(project.id, node); }} + className={`rounded-lg ${ + isProjectDropTarget + ? "bg-accent/35 ring-1 ring-border/60" + : "" + }`} > - - - - {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"} - - -
- - - - {visibleThreads.map((thread) => { - const isActive = routeThreadId === thread.id; - const threadStatus = threadStatusPill( - thread, - pendingApprovalByThreadId.get(thread.id) === true, - ); - const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id) - .runningTerminalIds, - ); - - return ( - - } +
+ + { - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onContextMenu={(event) => { - event.preventDefault(); - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > -
- {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - {prStatus.tooltip} - - )} - {threadStatus && ( - - - {threadStatus.label} - - )} - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(thread.id, renamingTitle, thread.title); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename(thread.id, renamingTitle, thread.title); - } - }} - onClick={(e) => e.stopPropagation()} + className="gap-2 px-2 py-1.5 pl-7 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground" + /> + } + onContextMenu={(event) => { + event.preventDefault(); + void handleProjectContextMenu(project.id, { + x: event.clientX, + y: event.clientY, + }); + }} + > + + + + {project.name} + + + + - ) : ( - - {thread.title} - - )} -
-
- {terminalStatus && ( - - - - )} - { + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(project.id); + }} + > + + + } + /> + + {newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"} + + +
+ + + + {visibleThreads.map((thread) => { + const isActive = routeThreadId === thread.id; + const threadStatus = threadStatusPill( + thread, + pendingApprovalByThreadId.get(thread.id) === + true, + ); + const prStatus = prStatusIndicator( + prByThreadId.get(thread.id) ?? null, + ); + const terminalStatus = + terminalStatusFromRunningIds( + selectThreadTerminalState( + terminalStateByThreadId, + thread.id, + ).runningTerminalIds, + ); + + return ( + + } + size="sm" + isActive={isActive} + className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left hover:bg-accent hover:text-foreground ${ + isActive + ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50" + : "text-muted-foreground" }`} + onClick={() => { + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }} + onKeyDown={(event) => { + if ( + event.key !== "Enter" && + event.key !== " " + ) + return; + event.preventDefault(); + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }} + onContextMenu={(event) => { + event.preventDefault(); + void handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + }} > - {formatRelativeTime(thread.createdAt)} - -
-
-
- ); - })} - - {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 - - - )} -
-
-
-
+
+ {prStatus && ( + + { + openPrLink( + event, + prStatus.url, + ); + }} + > + + + } + /> + + {prStatus.tooltip} + + + )} + {threadStatus && ( + + + + {threadStatus.label} + + + )} + {renamingThreadId === thread.id ? ( + { + if ( + el && + renamingInputRef.current !== el + ) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => + setRenamingTitle(e.target.value) + } + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename( + thread.id, + renamingTitle, + thread.title, + ); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename( + thread.id, + renamingTitle, + thread.title, + ); + } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {thread.title} + + )} +
+
+ {terminalStatus && ( + + + + )} + + {formatRelativeTime(thread.createdAt)} + +
+ + + ); + })} + + {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 + + + )} + + +
+
+ + {draggedProjectId !== null && + projectDropIndex === projectIndex + 1 && + projectIndex === projects.length - 1 && ( + +
+ + )} + ); })} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 2b29dd8ff859..7778f0a46987 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,8 +1,29 @@ -import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { + ProjectId, + ThreadId, + TurnId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { markThreadUnread, type AppState } from "./store"; -import type { Thread } from "./types"; +import { + markThreadUnread, + reorderProjects, + syncServerReadModel, + type AppState, +} from "./store"; +import type { Project, Thread } from "./types"; + +function makeProject(id: string, cwd = `/tmp/${id}`): Project { + return { + id: ProjectId.makeUnsafe(id), + name: id, + cwd, + model: "gpt-5-codex", + expanded: true, + scripts: [], + }; +} function makeThread(overrides: Partial = {}): Thread { return { @@ -24,25 +45,81 @@ function makeThread(overrides: Partial = {}): Thread { }; } -function makeState(thread: Thread): AppState { +function makeState( + thread: Thread, + projects: Project[] = [makeProject("project-1")], +): AppState { return { - projects: [ - { - id: ProjectId.makeUnsafe("project-1"), - name: "Project", - cwd: "/tmp/project", - model: "gpt-5-codex", - expanded: true, - scripts: [], - }, - ], + projects, threads: [thread], threadsHydrated: true, runtimeMode: "full-access", }; } +function makeReadModel( + projects: Array<{ id: string; cwd?: string }>, +): OrchestrationReadModel { + return { + snapshotSequence: 1, + projects: projects.map((project) => ({ + id: ProjectId.makeUnsafe(project.id), + title: project.id, + workspaceRoot: project.cwd ?? `/tmp/${project.id}`, + defaultModel: "gpt-5-codex", + scripts: [], + createdAt: "2026-02-25T12:00:00.000Z", + updatedAt: "2026-02-25T12:00:00.000Z", + deletedAt: null, + })), + threads: [], + updatedAt: "2026-02-25T12:00:00.000Z", + }; +} + describe("store pure functions", () => { + it("reorderProjects moves a project to the end insertion slot", () => { + const initialState = makeState(makeThread(), [ + makeProject("project-1"), + makeProject("project-2"), + makeProject("project-3"), + ]); + + const next = reorderProjects( + initialState, + ProjectId.makeUnsafe("project-1"), + 3, + ); + + expect(next.projects.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-2"), + ProjectId.makeUnsafe("project-3"), + ProjectId.makeUnsafe("project-1"), + ]); + }); + + it("syncServerReadModel keeps the existing local project order and appends new projects", () => { + const initialState = makeState(makeThread(), [ + makeProject("project-b", "/tmp/project-b"), + makeProject("project-a", "/tmp/project-a"), + ]); + + const next = syncServerReadModel( + initialState, + makeReadModel([ + { id: "project-a", cwd: "/tmp/project-a" }, + { id: "project-b", cwd: "/tmp/project-b" }, + { id: "project-c", cwd: "/tmp/project-c" }, + ]), + ); + + expect(next.projects.map((project) => project.cwd)).toEqual([ + "/tmp/project-b", + "/tmp/project-a", + "/tmp/project-c", + ]); + }); + it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; const initialState = makeState( @@ -59,7 +136,10 @@ describe("store pure functions", () => { }), ); - const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1")); + const next = markThreadUnread( + initialState, + ThreadId.makeUnsafe("thread-1"), + ); const updatedThread = next.threads[0]; expect(updatedThread).toBeDefined(); @@ -77,7 +157,10 @@ describe("store pure functions", () => { }), ); - const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1")); + const next = markThreadUnread( + initialState, + ThreadId.makeUnsafe("thread-1"), + ); expect(next).toEqual(initialState); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index e761bc30d472..d13ddc857a7f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -44,28 +44,37 @@ const initialState: AppState = { runtimeMode: DEFAULT_RUNTIME_MODE, }; const persistedExpandedProjectCwds = new Set(); +const persistedProjectOrderCwds: string[] = []; // ── Persist helpers ────────────────────────────────────────────────── function readPersistedState(): AppState { if (typeof window === "undefined") return initialState; try { + persistedExpandedProjectCwds.clear(); + persistedProjectOrderCwds.length = 0; const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); if (!raw) return initialState; const parsed = JSON.parse(raw) as { runtimeMode?: RuntimeMode; expandedProjectCwds?: string[]; + projectOrderCwds?: string[]; }; - persistedExpandedProjectCwds.clear(); for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); } } + for (const cwd of parsed.projectOrderCwds ?? []) { + if (typeof cwd === "string" && cwd.length > 0) { + persistedProjectOrderCwds.push(cwd); + } + } return { ...initialState, runtimeMode: - parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access" + parsed.runtimeMode === "approval-required" || + parsed.runtimeMode === "full-access" ? parsed.runtimeMode : DEFAULT_RUNTIME_MODE, }; @@ -84,6 +93,7 @@ function persistState(state: AppState): void { expandedProjectCwds: state.projects .filter((project) => project.expanded) .map((project) => project.cwd), + projectOrderCwds: state.projects.map((project) => project.cwd), }), ); for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { @@ -115,7 +125,7 @@ function mapProjectsFromReadModel( incoming: OrchestrationReadModel["projects"], previous: Project[], ): Project[] { - return incoming.map((project) => { + const mappedProjects = incoming.map((project) => { const existing = previous.find((entry) => entry.id === project.id) ?? previous.find((entry) => entry.cwd === project.workspaceRoot); @@ -123,7 +133,9 @@ function mapProjectsFromReadModel( id: project.id, name: project.title, cwd: project.workspaceRoot, - model: existing?.model ?? resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL), + model: + existing?.model ?? + resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL), expanded: existing?.expanded ?? (persistedExpandedProjectCwds.size > 0 @@ -132,6 +144,29 @@ function mapProjectsFromReadModel( scripts: project.scripts.map((script) => ({ ...script })), }; }); + const preferredProjectOrderCwds = + previous.length > 0 + ? previous.map((project) => project.cwd) + : persistedProjectOrderCwds; + if (preferredProjectOrderCwds.length === 0 || mappedProjects.length <= 1) { + return mappedProjects; + } + const orderByCwd = new Map( + preferredProjectOrderCwds.map((cwd, index) => [cwd, index] as const), + ); + const orderedProjects = mappedProjects.toSorted((left, right) => { + const leftIndex = orderByCwd.get(left.cwd); + const rightIndex = orderByCwd.get(right.cwd); + if (leftIndex === undefined && rightIndex === undefined) return 0; + if (leftIndex === undefined) return 1; + if (rightIndex === undefined) return -1; + return leftIndex - rightIndex; + }); + return orderedProjects.every( + (project, index) => project === mappedProjects[index], + ) + ? mappedProjects + : orderedProjects; } function toLegacySessionStatus( @@ -228,7 +263,9 @@ export function syncServerReadModel( activeTurnId: thread.session.activeTurnId ?? undefined, createdAt: thread.session.updatedAt, updatedAt: thread.session.updatedAt, - ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), + ...(thread.session.lastError + ? { lastError: thread.session.lastError } + : {}), } : null, messages: thread.messages.map((message) => { @@ -238,7 +275,9 @@ export function syncServerReadModel( name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, - previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), + previewUrl: toAttachmentPreviewUrl( + attachmentPreviewRoutePath(attachment.id), + ), })); const normalizedMessage: ChatMessage = { id: message.id, @@ -285,7 +324,9 @@ export function markThreadVisited( const at = visitedAt ?? new Date().toISOString(); const visitedAtMs = Date.parse(at); const threads = updateThread(state.threads, threadId, (thread) => { - const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; + const previousVisitedAtMs = thread.lastVisitedAt + ? Date.parse(thread.lastVisitedAt) + : NaN; if ( Number.isFinite(previousVisitedAtMs) && Number.isFinite(visitedAtMs) && @@ -298,7 +339,10 @@ export function markThreadVisited( return threads === state.threads ? state : { ...state, threads }; } -export function markThreadUnread(state: AppState, threadId: ThreadId): AppState { +export function markThreadUnread( + state: AppState, + threadId: ThreadId, +): AppState { const threads = updateThread(state.threads, threadId, (thread) => { if (!thread.latestTurn?.completedAt) return thread; const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); @@ -310,7 +354,10 @@ export function markThreadUnread(state: AppState, threadId: ThreadId): AppState return threads === state.threads ? state : { ...state, threads }; } -export function toggleProject(state: AppState, projectId: Project["id"]): AppState { +export function toggleProject( + state: AppState, + projectId: Project["id"], +): AppState { return { ...state, projects: state.projects.map((p) => @@ -333,7 +380,36 @@ export function setProjectExpanded( return changed ? { ...state, projects } : state; } -export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { +export function reorderProjects( + state: AppState, + sourceProjectId: Project["id"], + destinationIndex: number, +): AppState { + const sourceIndex = state.projects.findIndex( + (project) => project.id === sourceProjectId, + ); + if (sourceIndex === -1) return state; + const boundedDestinationIndex = Math.max( + 0, + Math.min(destinationIndex, state.projects.length), + ); + const normalizedDestinationIndex = + sourceIndex < boundedDestinationIndex + ? boundedDestinationIndex - 1 + : boundedDestinationIndex; + if (normalizedDestinationIndex === sourceIndex) return state; + const projects = [...state.projects]; + const [movedProject] = projects.splice(sourceIndex, 1); + if (!movedProject) return state; + projects.splice(normalizedDestinationIndex, 0, movedProject); + return { ...state, projects }; +} + +export function setError( + state: AppState, + threadId: ThreadId, + error: string | null, +): AppState { const threads = updateThread(state.threads, threadId, (t) => { if (t.error === error) return t; return { ...t, error }; @@ -373,6 +449,10 @@ interface AppStore extends AppState { markThreadUnread: (threadId: ThreadId) => void; toggleProject: (projectId: Project["id"]) => void; setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; + reorderProjects: ( + sourceProjectId: Project["id"], + destinationIndex: number, + ) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: ( threadId: ThreadId, @@ -390,19 +470,19 @@ export const useStore = create((set) => ({ set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId) => set((state) => markThreadUnread(state, threadId)), - toggleProject: (projectId) => - set((state) => toggleProject(state, projectId)), + toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), + reorderProjects: (sourceProjectId, destinationIndex) => + set((state) => reorderProjects(state, sourceProjectId, destinationIndex)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), - setRuntimeMode: (mode) => - set((state) => setRuntimeMode(state, mode)), + setRuntimeMode: (mode) => set((state) => setRuntimeMode(state, mode)), })); -// Persist on every state change (only runtimeMode + expandedProjectCwds) +// Persist on every state change (runtimeMode + sidebar project UI state) useStore.subscribe((state) => persistState(state)); export function StoreProvider({ children }: { children: ReactNode }) {