From a8a5f7cfbca0cae970d1c6e781da1c74fef901ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Mar 2026 02:50:12 +0000 Subject: [PATCH 1/2] Add sidebar project reordering Co-authored-by: Theo Browne --- apps/web/src/components/Sidebar.tsx | 517 ++++++++++++++++------------ apps/web/src/store.test.ts | 86 ++++- apps/web/src/store.ts | 55 ++- 3 files changed, 413 insertions(+), 245 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 018e89d14b64..a3c733a610a6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,11 +2,12 @@ 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, @@ -261,6 +262,7 @@ export default function Sidebar() { const threads = useStore((store) => store.threads); const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); + const reorderProjects = useStore((store) => store.reorderProjects); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, @@ -297,6 +299,8 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => 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); @@ -982,6 +986,11 @@ export default function Sidebar() { ); + const resetProjectDrag = () => { + setDraggedProjectId(null); + setProjectDropIndex(null); + }; + return ( <> {isElectron ? ( @@ -1018,7 +1027,7 @@ export default function Sidebar() { - {projects.map((project) => { + {projects.map((project, projectIndex) => { const projectThreads = threads .filter((thread) => thread.projectId === project.id) .toSorted((a, b) => { @@ -1034,246 +1043,298 @@ export default function Sidebar() { : projectThreads; return ( - { - if (open === project.expanded) return; - toggleProject(project.id); - }} - > - -
- - } - onContextMenu={(event) => { - event.preventDefault(); - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - - - - {project.name} - - - - + {draggedProjectId !== null && projectDropIndex === projectIndex && ( + +
+ + )} + { + if (open === project.expanded) return; + toggleProject(project.id); + }} + > + +
+ - } - showOnHover - className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" - onClick={(event) => { + { + setDraggedProjectId(project.id); + setProjectDropIndex(null); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", project.cwd); + }} + onDragEnd={() => { + resetProjectDrag(); + }} + onDragOver={(event) => { + if (draggedProjectId === null) return; event.preventDefault(); - event.stopPropagation(); - void handleNewThread(project.id); + event.dataTransfer.dropEffect = "move"; + const bounds = event.currentTarget.getBoundingClientRect(); + const nextDropIndex = + event.clientY < bounds.top + bounds.height / 2 + ? projectIndex + : projectIndex + 1; + if (projectDropIndex !== nextDropIndex) { + setProjectDropIndex(nextDropIndex); + } }} - > - - + onDrop={(event) => { + if (draggedProjectId === null) return; + event.preventDefault(); + const bounds = event.currentTarget.getBoundingClientRect(); + const destinationIndex = + event.clientY < bounds.top + bounds.height / 2 + ? projectIndex + : projectIndex + 1; + reorderProjects(draggedProjectId, destinationIndex); + resetProjectDrag(); + }} + /> } - /> - - {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) => { + onContextMenu={(event) => { + event.preventDefault(); + void handleProjectContextMenu(project.id, { + x: event.clientX, + y: event.clientY, + }); + }} + > + + + + + {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(); - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); + event.stopPropagation(); + void handleNewThread(project.id); }} > -
- {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - {prStatus.tooltip} - - )} - {threadStatus && ( - + + + } + /> + + {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, + }); + }} + > +
+ {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()} /> - {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 && ( + ) : ( + + {thread.title} + + )} +
+
+ {terminalStatus && ( + + + + )} - + {formatRelativeTime(thread.createdAt)} - )} - - {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={() => { - 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 - - - )} - - -
- + )} + {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..ddb59d735705 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,8 +1,19 @@ -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 +35,72 @@ 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( diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index e761bc30d472..97a433ac1f04 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -44,24 +44,32 @@ 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: @@ -84,6 +92,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 +124,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); @@ -132,6 +141,25 @@ 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( @@ -333,6 +361,24 @@ export function setProjectExpanded( return changed ? { ...state, projects } : state; } +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; @@ -373,6 +419,7 @@ 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, @@ -394,6 +441,8 @@ export const useStore = create((set) => ({ 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) => @@ -402,7 +451,7 @@ export const useStore = create((set) => ({ 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 }) { From a1cb4e4bd56a6e60af3481190f241e81aff2866c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Mar 2026 03:26:24 +0000 Subject: [PATCH 2/2] Fix sidebar project reorder dragging Co-authored-by: Theo Browne --- apps/web/src/components/Sidebar.tsx | 991 ++++++++++++++++++---------- apps/web/src/store.test.ts | 39 +- apps/web/src/store.ts | 67 +- 3 files changed, 723 insertions(+), 374 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a3c733a610a6..09b9c6ec2689 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,7 +7,14 @@ import { SquarePenIcon, TerminalIcon, } from "lucide-react"; -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { DEFAULT_MODEL, type DesktopUpdateState, @@ -16,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, @@ -41,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, @@ -58,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); @@ -114,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", @@ -238,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 ( @@ -263,15 +305,25 @@ export default function Sidebar() { const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); const reorderProjects = useStore((store) => store.reorderProjects); - const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); + 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, ); @@ -282,28 +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 [draggedProjectId, setDraggedProjectId] = useState(null); + 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) { @@ -312,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( @@ -320,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], ); @@ -357,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( ( @@ -403,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 } : {}), }); } @@ -419,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 } : {}), }); } @@ -463,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]; @@ -518,7 +597,12 @@ export default function Sidebar() { } finishAddingProject(); }, - [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects], + [ + focusMostRecentThreadForProject, + handleNewThread, + isAddingProject, + projects, + ], ); const handleAddProject = () => { @@ -558,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; } @@ -582,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(); @@ -629,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; @@ -646,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( @@ -684,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(), @@ -716,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", @@ -757,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", @@ -768,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; @@ -784,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", @@ -807,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); @@ -818,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"), }); }; @@ -832,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; @@ -867,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"; @@ -887,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") ?? @@ -898,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 @@ -908,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; @@ -924,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; @@ -947,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) => { @@ -991,6 +1132,112 @@ export default function Sidebar() { 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 ? ( @@ -1013,7 +1260,9 @@ export default function Sidebar() { } /> - {desktopUpdateTooltip} + + {desktopUpdateTooltip} + )} @@ -1031,24 +1280,41 @@ export default function Sidebar() { 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 ( - {draggedProjectId !== null && projectDropIndex === projectIndex && ( - -
- - )} + {draggedProjectId !== null && + projectDropIndex === projectIndex && ( + +
+ + )} - -
- { - setDraggedProjectId(project.id); - setProjectDropIndex(null); - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", project.cwd); - }} - onDragEnd={() => { - resetProjectDrag(); - }} - onDragOver={(event) => { - if (draggedProjectId === null) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - const bounds = event.currentTarget.getBoundingClientRect(); - const nextDropIndex = - event.clientY < bounds.top + bounds.height / 2 - ? projectIndex - : projectIndex + 1; - if (projectDropIndex !== nextDropIndex) { - setProjectDropIndex(nextDropIndex); - } - }} - onDrop={(event) => { - if (draggedProjectId === null) return; - event.preventDefault(); - const bounds = event.currentTarget.getBoundingClientRect(); - const destinationIndex = - event.clientY < bounds.top + bounds.height / 2 - ? projectIndex - : projectIndex + 1; - reorderProjects(draggedProjectId, destinationIndex); - resetProjectDrag(); - }} - /> - } - onContextMenu={(event) => { - event.preventDefault(); - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - - - - - {project.name} - - - - +
{ + setProjectBlockRef(project.id, node); + }} + className={`rounded-lg ${ + isProjectDropTarget + ? "bg-accent/35 ring-1 ring-border/60" + : "" + }`} + > +
+ + - } - 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 ( - - } - 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) => { + onContextMenu={(event) => { + event.preventDefault(); + void handleProjectContextMenu(project.id, { + x: event.clientX, + y: event.clientY, + }); + }} + > + + + + {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(); - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); + event.stopPropagation(); + void handleNewThread(project.id); }} > -
- {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - + + + } + /> + + {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, + }); + }} + > +
+ {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()} /> - {prStatus.tooltip} - - )} - {threadStatus && ( - + ) : ( + + {thread.title} + + )} +
+
+ {terminalStatus && ( - {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 && ( + role="img" + aria-label={terminalStatus.label} + title={terminalStatus.label} + className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} + > + + + )} - + {formatRelativeTime(thread.createdAt)} - )} - - {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={() => { - 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 - - - )} - - + )} + {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 ddb59d735705..7778f0a46987 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,7 +1,17 @@ -import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3tools/contracts"; +import { + ProjectId, + ThreadId, + TurnId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { markThreadUnread, reorderProjects, syncServerReadModel, type AppState } from "./store"; +import { + markThreadUnread, + reorderProjects, + syncServerReadModel, + type AppState, +} from "./store"; import type { Project, Thread } from "./types"; function makeProject(id: string, cwd = `/tmp/${id}`): Project { @@ -35,7 +45,10 @@ function makeThread(overrides: Partial = {}): Thread { }; } -function makeState(thread: Thread, projects: Project[] = [makeProject("project-1")]): AppState { +function makeState( + thread: Thread, + projects: Project[] = [makeProject("project-1")], +): AppState { return { projects, threads: [thread], @@ -44,7 +57,9 @@ function makeState(thread: Thread, projects: Project[] = [makeProject("project-1 }; } -function makeReadModel(projects: Array<{ id: string; cwd?: string }>): OrchestrationReadModel { +function makeReadModel( + projects: Array<{ id: string; cwd?: string }>, +): OrchestrationReadModel { return { snapshotSequence: 1, projects: projects.map((project) => ({ @@ -70,7 +85,11 @@ describe("store pure functions", () => { makeProject("project-3"), ]); - const next = reorderProjects(initialState, ProjectId.makeUnsafe("project-1"), 3); + const next = reorderProjects( + initialState, + ProjectId.makeUnsafe("project-1"), + 3, + ); expect(next.projects.map((project) => project.id)).toEqual([ ProjectId.makeUnsafe("project-2"), @@ -117,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(); @@ -135,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 97a433ac1f04..d13ddc857a7f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -73,7 +73,8 @@ function readPersistedState(): AppState { return { ...initialState, runtimeMode: - parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access" + parsed.runtimeMode === "approval-required" || + parsed.runtimeMode === "full-access" ? parsed.runtimeMode : DEFAULT_RUNTIME_MODE, }; @@ -132,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 @@ -142,7 +145,9 @@ function mapProjectsFromReadModel( }; }); const preferredProjectOrderCwds = - previous.length > 0 ? previous.map((project) => project.cwd) : persistedProjectOrderCwds; + previous.length > 0 + ? previous.map((project) => project.cwd) + : persistedProjectOrderCwds; if (preferredProjectOrderCwds.length === 0 || mappedProjects.length <= 1) { return mappedProjects; } @@ -157,7 +162,9 @@ function mapProjectsFromReadModel( if (rightIndex === undefined) return -1; return leftIndex - rightIndex; }); - return orderedProjects.every((project, index) => project === mappedProjects[index]) + return orderedProjects.every( + (project, index) => project === mappedProjects[index], + ) ? mappedProjects : orderedProjects; } @@ -256,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) => { @@ -266,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, @@ -313,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) && @@ -326,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); @@ -338,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) => @@ -366,11 +385,18 @@ export function reorderProjects( sourceProjectId: Project["id"], destinationIndex: number, ): AppState { - const sourceIndex = state.projects.findIndex((project) => project.id === sourceProjectId); + 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 boundedDestinationIndex = Math.max( + 0, + Math.min(destinationIndex, state.projects.length), + ); const normalizedDestinationIndex = - sourceIndex < boundedDestinationIndex ? boundedDestinationIndex - 1 : boundedDestinationIndex; + sourceIndex < boundedDestinationIndex + ? boundedDestinationIndex - 1 + : boundedDestinationIndex; if (normalizedDestinationIndex === sourceIndex) return state; const projects = [...state.projects]; const [movedProject] = projects.splice(sourceIndex, 1); @@ -379,7 +405,11 @@ export function reorderProjects( return { ...state, projects }; } -export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { +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 }; @@ -419,7 +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; + reorderProjects: ( + sourceProjectId: Project["id"], + destinationIndex: number, + ) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: ( threadId: ThreadId, @@ -437,8 +470,7 @@ 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) => @@ -447,8 +479,7 @@ export const useStore = create((set) => ({ 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 (runtimeMode + sidebar project UI state)