From 967e470a26edf3a02864220f5badecb28016bee8 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:18:36 +0100 Subject: [PATCH 1/9] Refactor sidebar rerender boundaries --- apps/web/src/components/Sidebar.logic.ts | 2 +- apps/web/src/components/Sidebar.tsx | 3048 +++++++++-------- .../components/sidebar/sidebarSelectors.ts | 390 +++ 3 files changed, 2023 insertions(+), 1417 deletions(-) create mode 100644 apps/web/src/components/sidebar/sidebarSelectors.ts diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ed151c6da8..6abd16dd62 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -5,7 +5,7 @@ import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; -export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; +export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 0; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bb63db6fc0..b260020a1a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -14,6 +14,8 @@ import { import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; +import { useStore as useZustandStore } from "zustand"; +import { createStore } from "zustand/vanilla"; import { useShallow } from "zustand/react/shallow"; import { DndContext, @@ -42,6 +44,7 @@ import { type GitStatusResult, } from "@t3tools/contracts"; import { + parseScopedThreadKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, @@ -60,9 +63,9 @@ import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../l import { selectProjectByRef, selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRef, + selectSidebarThreadSummaryByRef, selectSidebarThreadsForProjectRefs, - selectSidebarThreadsAcrossEnvironments, + selectThreadIdsByProjectRef, selectThreadByRef, useStore, } from "../store"; @@ -134,6 +137,15 @@ import { useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; +import { + createSidebarProjectRenderStateSelector, + createSidebarProjectThreadStatusInputsSelector, + createSidebarThreadRowSnapshotSelectorByRef, + createSidebarThreadStatusInputSelectorByRef, + createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector, + type ProjectThreadStatusInput, + type SidebarThreadSortSnapshot, +} from "./sidebar/sidebarSelectors"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { readEnvironmentApi } from "../environmentApi"; @@ -144,7 +156,7 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { Project, SidebarThreadSummary } from "../types"; +import type { Project } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -160,23 +172,115 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); +const EMPTY_SIDEBAR_PROJECT_KEYS: string[] = []; +const EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY = new Map(); +const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); + +interface SidebarTransientState { + sortedProjectKeys: readonly string[]; + projectSnapshotByKey: ReadonlyMap; + physicalToLogicalKey: ReadonlyMap; + activeRouteThreadKey: string | null; + activeRouteProjectKey: string | null; + threadJumpLabelByKey: ReadonlyMap; + expandedThreadListsByProject: ReadonlySet; +} -function threadJumpLabelMapsEqual( - left: ReadonlyMap, - right: ReadonlyMap, -): boolean { - if (left === right) { - return true; - } - if (left.size !== right.size) { - return false; +const sidebarTransientStore = createStore(() => ({ + sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, + projectSnapshotByKey: EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY, + physicalToLogicalKey: new Map(), + activeRouteThreadKey: null, + activeRouteProjectKey: null, + threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, + expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, +})); + +function useSidebarProjectKeys(): readonly string[] { + return useZustandStore(sidebarTransientStore, (state) => state.sortedProjectKeys); +} + +function useSidebarPhysicalToLogicalKey(): ReadonlyMap { + return useZustandStore(sidebarTransientStore, (state) => state.physicalToLogicalKey); +} + +function useSidebarProjectSnapshot(projectKey: string): SidebarProjectSnapshot | null { + return useZustandStore( + sidebarTransientStore, + useCallback( + (state: SidebarTransientState) => state.projectSnapshotByKey.get(projectKey) ?? null, + [projectKey], + ), + ); +} + +function useSidebarProjectThreadListExpanded(projectKey: string): boolean { + return useZustandStore( + sidebarTransientStore, + useCallback( + (state: SidebarTransientState) => state.expandedThreadListsByProject.has(projectKey), + [projectKey], + ), + ); +} + +function expandSidebarProjectThreadList(projectKey: string): void { + const { expandedThreadListsByProject } = sidebarTransientStore.getState(); + if (expandedThreadListsByProject.has(projectKey)) { + return; } - for (const [key, value] of left) { - if (right.get(key) !== value) { - return false; - } + + sidebarTransientStore.setState({ + expandedThreadListsByProject: new Set([...expandedThreadListsByProject, projectKey]), + }); +} + +function collapseSidebarProjectThreadList(projectKey: string): void { + const { expandedThreadListsByProject } = sidebarTransientStore.getState(); + if (!expandedThreadListsByProject.has(projectKey)) { + return; } - return true; + + const nextExpandedThreadListsByProject = new Set(expandedThreadListsByProject); + nextExpandedThreadListsByProject.delete(projectKey); + sidebarTransientStore.setState({ + expandedThreadListsByProject: + nextExpandedThreadListsByProject.size === 0 + ? EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT + : nextExpandedThreadListsByProject, + }); +} + +function useSidebarIsActiveThread(threadKey: string): boolean { + return useZustandStore( + sidebarTransientStore, + useCallback( + (state: SidebarTransientState) => state.activeRouteThreadKey === threadKey, + [threadKey], + ), + ); +} + +function useSidebarThreadJumpLabel(threadKey: string): string | null { + return useZustandStore( + sidebarTransientStore, + useCallback( + (state: SidebarTransientState) => state.threadJumpLabelByKey.get(threadKey) ?? null, + [threadKey], + ), + ); +} + +function useSidebarProjectActiveRouteThreadKey(projectKey: string): string | null { + return useZustandStore( + sidebarTransientStore, + useCallback( + (state: SidebarTransientState) => { + return state.activeRouteProjectKey === projectKey ? state.activeRouteThreadKey : null; + }, + [projectKey], + ), + ); } function buildThreadJumpLabelMap(input: { @@ -218,6 +322,44 @@ type SidebarProjectSnapshot = Project & { /** Labels for remote environments this project lives in. */ remoteEnvironmentLabels: readonly string[]; }; + +function refsEqual(left: readonly ScopedProjectRef[], right: readonly ScopedProjectRef[]): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.projectId === right[index]?.projectId, + ) + ); +} + +function stringsEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function sidebarProjectSnapshotsEqual( + left: SidebarProjectSnapshot | undefined, + right: SidebarProjectSnapshot, +): boolean { + return ( + left !== undefined && + left.id === right.id && + left.environmentId === right.environmentId && + left.name === right.name && + left.cwd === right.cwd && + left.repositoryIdentity === right.repositoryIdentity && + left.defaultModelSelection === right.defaultModelSelection && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + left.scripts === right.scripts && + left.projectKey === right.projectKey && + left.environmentPresence === right.environmentPresence && + refsEqual(left.memberProjectRefs, right.memberProjectRefs) && + stringsEqual(left.remoteEnvironmentLabels, right.remoteEnvironmentLabels) + ); +} + interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -233,6 +375,19 @@ interface PrStatusIndicator { type ThreadPr = GitStatusResult["pr"]; +function useSidebarThreadStatusInput( + threadRef: ScopedThreadRef | null, +): ProjectThreadStatusInput | undefined { + return useStore( + useMemo(() => createSidebarThreadStatusInputSelectorByRef(threadRef), [threadRef]), + ); +} + +function useSidebarThreadRunningState(threadRef: ScopedThreadRef | null): boolean { + const statusInput = useSidebarThreadStatusInput(threadRef); + return statusInput?.session?.status === "running" && statusInput.session.activeTurnId != null; +} + function ThreadStatusLabel({ status, compact = false, @@ -325,77 +480,299 @@ function resolveThreadPr( return gitStatus.pr ?? null; } -interface SidebarThreadRowProps { - thread: SidebarThreadSummary; - projectCwd: string | null; - orderedProjectThreadKeys: readonly string[]; - isActive: boolean; - jumpLabel: string | null; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; -} +const SidebarThreadMetaInfo = memo(function SidebarThreadMetaInfo(props: { + threadKey: string; + threadRef: ScopedThreadRef; + isHighlighted: boolean; + isRemoteThread: boolean; + threadEnvironmentLabel: string | null; + isConfirmingArchive: boolean; +}) { + const { + threadKey, + threadRef, + isHighlighted, + isRemoteThread, + threadEnvironmentLabel, + isConfirmingArchive, + } = props; + const jumpLabel = useSidebarThreadJumpLabel(threadKey); + const isThreadRunning = useSidebarThreadRunningState(threadRef); + const hidden = isConfirmingArchive && !isThreadRunning; + const relativeTimestamp = useStore( + useMemo( + () => (state: import("../store").AppState) => { + const thread = selectSidebarThreadSummaryByRef(state, threadRef); + return thread ? formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt) : null; + }, + [threadRef], + ), + ); -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + return ( + + ); +}); + +const SidebarThreadStatusIndicator = memo(function SidebarThreadStatusIndicator(props: { + threadKey: string; + threadRef: ScopedThreadRef; +}) { + const { threadKey, threadRef } = props; + const statusInput = useSidebarThreadStatusInput(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const threadStatus = useMemo( + () => + statusInput + ? resolveThreadStatusPill({ + thread: { + ...statusInput, + lastVisitedAt, + }, + }) + : null, + [lastVisitedAt, statusInput], + ); + + return threadStatus ? : null; +}); + +const SidebarThreadTerminalStatusIndicator = memo( + function SidebarThreadTerminalStatusIndicator(props: { threadRef: ScopedThreadRef }) { + const { threadRef } = props; + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, + ); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + + return terminalStatus ? ( + + + + ) : null; + }, +); + +const SidebarThreadArchiveControl = memo(function SidebarThreadArchiveControl(props: { + appSettingsConfirmThreadArchive: boolean; + confirmArchiveButtonRef: React.RefObject; + handleArchiveImmediateClick: (event: React.MouseEvent) => void; + handleConfirmArchiveClick: (event: React.MouseEvent) => void; + handleStartArchiveConfirmation: (event: React.MouseEvent) => void; + isConfirmingArchive: boolean; + stopPropagationOnPointerDown: (event: React.PointerEvent) => void; + threadId: ThreadId; + threadRef: ScopedThreadRef; + threadTitle: string; +}) { const { - orderedProjectThreadKeys, - isActive, - jumpLabel, appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - thread, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchive, + stopPropagationOnPointerDown, + threadId, + threadRef, + threadTitle, } = props; - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const threadKey = scopedThreadKey(threadRef); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isThreadRunning = useSidebarThreadRunningState(threadRef); + const isConfirmingArchiveVisible = isConfirmingArchive && !isThreadRunning; + + if (isConfirmingArchiveVisible) { + return ( + + ); + } + + if (isThreadRunning) { + return null; + } + + if (appSettingsConfirmThreadArchive) { + return ( +
+ +
+ ); + } + + return ( + + + + + } + /> + Archive + + ); +}); + +interface SidebarThreadRowProps { + threadKey: string; + projectKey: string; +} + +const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const { threadKey, projectKey } = props; + const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); + const project = useSidebarProjectSnapshot(projectKey); + const threadSortOrder = useSettings( + (settings) => settings.sidebarThreadSortOrder, + ); + const appSettingsConfirmThreadDelete = useSettings( + (settings) => settings.confirmThreadDelete, + ); + const appSettingsConfirmThreadArchive = useSettings( + (settings) => settings.confirmThreadArchive, + ); + const router = useRouter(); + const { archiveThread, deleteThread } = useThreadActions(); + const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); + const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); + const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); + const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ + threadId: ThreadId; + }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: ctx.threadId, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ + path: string; + }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Path copied", + description: ctx.path, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); + const [isRenaming, setIsRenaming] = useState(false); + const [renamingTitle, setRenamingTitle] = useState(""); + const [isConfirmingArchive, setIsConfirmingArchive] = useState(false); + const renamingCommittedRef = useRef(false); + const renamingInputRef = useRef(null); + const confirmArchiveButtonRef = useRef(null); + if (!threadRef) { + return null; + } + const thread = useStore( + useMemo(() => createSidebarThreadRowSnapshotSelectorByRef(threadRef), [threadRef]), + ); + const isActive = useSidebarIsActiveThread(threadKey); + if (!thread) { + return null; + } const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); - const runningTerminalIds = useTerminalStateStore( - (state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, - ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; @@ -419,32 +796,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP [thread.environmentId, thread.projectId], ), ); - const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; + const gitCwd = thread.worktreePath ?? threadProjectCwd ?? project?.cwd ?? null; const gitStatus = useGitStatus({ environmentId: thread.environmentId, cwd: thread.branch != null ? gitCwd : null, }); const isHighlighted = isActive || isSelected; - const isThreadRunning = - thread.session?.status === "running" && thread.session.activeTurnId != null; - const threadStatus = resolveThreadStatusPill({ - thread: { - ...thread, - lastVisitedAt, - }, - }); const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; - const threadMetaClassName = isConfirmingArchive - ? "pointer-events-none opacity-0" - : !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; const clearConfirmingArchive = useCallback(() => { - setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); - }, [setConfirmingArchiveThreadKey, threadKey]); + setIsConfirmingArchive(false); + }, []); const handleMouseLeave = useCallback(() => { clearConfirmingArchive(); }, [clearConfirmingArchive]); @@ -460,46 +822,306 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, [clearConfirmingArchive], ); - const handleRowClick = useCallback( - (event: React.MouseEvent) => { - handleThreadClick(event, threadRef, orderedProjectThreadKeys); - }, - [handleThreadClick, orderedProjectThreadKeys, threadRef], - ); - const handleRowKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(threadRef); - }, - [navigateToThread, threadRef], - ); - const handleRowContextMenu = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - if (hasSelection && isSelected) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - return; - } + const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); - if (hasSelection) { + const api = readLocalApi(); + 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.", + }); + }); + }, []); + const navigateToThread = useCallback( + (targetThreadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { clearSelection(); } - void handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, + setSelectionAnchor(scopedThreadKey(targetThreadRef)); + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(targetThreadRef), }); }, - [ - clearSelection, - handleMultiSelectContextMenu, - handleThreadContextMenu, - hasSelection, - isSelected, - threadRef, + [clearSelection, router, setSelectionAnchor], + ); + const attemptArchiveThread = useCallback(async () => { + try { + await archiveThread(threadRef); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, [archiveThread, threadRef]); + const cancelRename = useCallback(() => { + setIsRenaming(false); + setRenamingTitle(""); + renamingCommittedRef.current = false; + renamingInputRef.current = null; + }, []); + const commitRename = useCallback(async () => { + const finishRename = () => { + setIsRenaming(false); + renamingCommittedRef.current = false; + renamingInputRef.current = null; + }; + + const trimmed = renamingTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Thread title cannot be empty", + }); + finishRename(); + return; + } + if (trimmed === thread.title) { + finishRename(); + return; + } + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + finishRename(); + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: threadRef.threadId, + title: trimmed, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + finishRename(); + }, [renamingTitle, thread.title, threadRef]); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + if (threadKeys.length === 0) return; + const count = threadKeys.length; + + const clicked = await api.contextMenu.show( + [ + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ); + + if (clicked === "mark-unread") { + const appState = useStore.getState(); + for (const selectedThreadKey of threadKeys) { + const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); + if (!selectedThreadRef) continue; + const selectedThread = selectSidebarThreadSummaryByRef(appState, selectedThreadRef); + markThreadUnread(selectedThreadKey, selectedThread?.latestTurn?.completedAt); + } + clearSelection(); + return; + } + + if (clicked !== "delete") return; + + if (appSettingsConfirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ); + if (!confirmed) return; + } + + const deletedThreadKeys = new Set(threadKeys); + for (const selectedThreadKey of threadKeys) { + const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); + if (!selectedThreadRef) continue; + await deleteThread(selectedThreadRef, { deletedThreadKeys }); + } + removeFromSelection(threadKeys); + }, + [ + appSettingsConfirmThreadDelete, + clearSelection, + deleteThread, + markThreadUnread, + removeFromSelection, + ], + ); + const handleThreadContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadWorkspacePath = thread.worktreePath ?? project?.cwd ?? null; + const currentThreadSummary = selectSidebarThreadSummaryByRef(useStore.getState(), threadRef); + const clicked = await api.contextMenu.show( + [ + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy Path" }, + { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + + if (clicked === "rename") { + setIsRenaming(true); + setRenamingTitle(thread.title); + renamingCommittedRef.current = false; + return; + } + + if (clicked === "mark-unread") { + markThreadUnread(threadKey, currentThreadSummary?.latestTurn?.completedAt); + return; + } + if (clicked === "copy-path") { + if (!threadWorkspacePath) { + toastManager.add({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }); + return; + } + copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + return; + } + if (clicked === "copy-thread-id") { + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + return; + } + if (clicked !== "delete") return; + if (appSettingsConfirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + } + await deleteThread(threadRef); + }, + [ + appSettingsConfirmThreadDelete, + copyPathToClipboard, + copyThreadIdToClipboard, + deleteThread, + markThreadUnread, + project?.cwd, + thread, + threadKey, + threadRef, + ], + ); + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const isShiftClick = event.shiftKey; + const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size; + + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + + if (isShiftClick) { + event.preventDefault(); + const projectSnapshot = sidebarTransientStore + .getState() + .projectSnapshotByKey.get(projectKey); + const orderedProjectThreadKeys = projectSnapshot + ? sortThreadsForSidebar( + selectSidebarThreadsForProjectRefs( + useStore.getState(), + projectSnapshot.memberProjectRefs, + ).filter((projectThread) => projectThread.archivedAt === null), + threadSortOrder, + ).map((projectThread) => + scopedThreadKey(scopeThreadRef(projectThread.environmentId, projectThread.id)), + ) + : [threadKey]; + rangeSelectTo(threadKey, orderedProjectThreadKeys); + return; + } + + if (currentSelectionCount > 0) { + clearSelection(); + } + navigateToThread(threadRef); + }, + [ + clearSelection, + navigateToThread, + projectKey, + rangeSelectTo, + threadKey, + threadRef, + threadSortOrder, + toggleThreadSelection, + ], + ); + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }, + [navigateToThread, threadRef], + ); + const handleRowContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (hasSelection && isSelected) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + return; + } + + if (hasSelection) { + clearSelection(); + } + void handleThreadContextMenu({ + x: event.clientX, + y: event.clientY, + }); + }, + [ + clearSelection, + handleMultiSelectContextMenu, + handleThreadContextMenu, + hasSelection, + isSelected, ], ); const handlePrClick = useCallback( @@ -531,33 +1153,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP if (event.key === "Enter") { event.preventDefault(); renamingCommittedRef.current = true; - void commitRename(threadRef, renamingTitle, thread.title); + void commitRename(); } else if (event.key === "Escape") { event.preventDefault(); renamingCommittedRef.current = true; cancelRename(); } }, - [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], + [cancelRename, commitRename, renamingCommittedRef], ); const handleRenameInputBlur = useCallback(() => { if (!renamingCommittedRef.current) { - void commitRename(threadRef, renamingTitle, thread.title); + void commitRename(); } - }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + }, [commitRename, renamingCommittedRef]); const handleRenameInputClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); }, []); - const handleConfirmArchiveRef = useCallback( - (element: HTMLButtonElement | null) => { - if (element) { - confirmArchiveButtonRefs.current.set(threadKey, element); - } else { - confirmArchiveButtonRefs.current.delete(threadKey); - } - }, - [confirmArchiveButtonRefs, threadKey], - ); const stopPropagationOnPointerDown = useCallback( (event: React.PointerEvent) => { event.stopPropagation(); @@ -569,28 +1181,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP event.preventDefault(); event.stopPropagation(); clearConfirmingArchive(); - void attemptArchiveThread(threadRef); + void attemptArchiveThread(); }, - [attemptArchiveThread, clearConfirmingArchive, threadRef], + [attemptArchiveThread, clearConfirmingArchive], ); const handleStartArchiveConfirmation = useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); - setConfirmingArchiveThreadKey(threadKey); + setIsConfirmingArchive(true); requestAnimationFrame(() => { - confirmArchiveButtonRefs.current.get(threadKey)?.focus(); + confirmArchiveButtonRef.current?.focus(); }); }, - [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], + [], ); const handleArchiveImmediateClick = useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); - void attemptArchiveThread(threadRef); + void attemptArchiveThread(); }, - [attemptArchiveThread, threadRef], + [attemptArchiveThread], ); const rowButtonRender = useMemo(() =>
, []); @@ -632,8 +1244,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {prStatus.tooltip} )} - {threadStatus && } - {renamingThreadKey === threadKey ? ( + + {isRenaming ? (
- {terminalStatus && ( - - - - )} +
- {isConfirmingArchive ? ( - - ) : !isThreadRunning ? ( - appSettingsConfirmThreadArchive ? ( -
- -
- ) : ( - - - -
- } - /> - Archive - - ) - ) : null} - - - {isRemoteThread && ( - - - } - > - - - {threadEnvironmentLabel} - - )} - {jumpLabel ? ( - - {jumpLabel} - - ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - - )} - - + +
@@ -758,47 +1293,12 @@ interface SidebarProjectThreadListProps { projectKey: string; projectExpanded: boolean; hasOverflowingThreads: boolean; - hiddenThreadStatus: ThreadStatusPill | null; - orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; + hiddenThreadKeys: readonly string[]; + renderedThreadKeys: readonly string[]; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; - projectCwd: string; - activeRouteThreadKey: string | null; - threadJumpLabelByKey: ReadonlyMap; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; } const SidebarProjectThreadList = memo(function SidebarProjectThreadList( @@ -808,36 +1308,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( projectKey, projectExpanded, hasOverflowingThreads, - hiddenThreadStatus, - orderedProjectThreadKeys, - renderedThreads, + hiddenThreadKeys, + renderedThreadKeys, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, - projectCwd, - activeRouteThreadKey, - threadJumpLabelByKey, - appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, attachThreadListAutoAnimateRef, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - expandThreadListForProject, - collapseThreadListForProject, } = props; const showMoreButtonRender = useMemo(() => - + } - /> + > + + - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + Remote environment: {project.remoteEnvironmentLabels.join(", ")} - + )} + + + + + } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + + + ); +}); + +interface SidebarProjectItemProps { + projectKey: string; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + isManualProjectSorting: boolean; + dragHandleProps: SortableProjectHandleProps | null; +} + +const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { + const { + projectKey, + attachThreadListAutoAnimateRef, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + isManualProjectSorting, + dragHandleProps, + } = props; - + + + ); @@ -2033,18 +2199,7 @@ interface SidebarProjectsContentProps { handleProjectDragStart: (event: DragStartEvent) => void; handleProjectDragEnd: (event: DragEndEvent) => void; handleProjectDragCancel: (event: DragCancelEvent) => void; - handleNewThread: ReturnType["handleNewThread"]; - archiveThread: ReturnType["archiveThread"]; - deleteThread: ReturnType["deleteThread"]; - sortedProjects: readonly SidebarProjectSnapshot[]; - expandedThreadListsByProject: ReadonlySet; - activeRouteProjectKey: string | null; - routeThreadKey: string | null; - newThreadShortcutLabel: string | null; - threadJumpLabelByKey: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; dragInProgressRef: React.RefObject; suppressProjectClickAfterDragRef: React.RefObject; suppressProjectClickForContextMenuRef: React.RefObject; @@ -2084,24 +2239,14 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleProjectDragStart, handleProjectDragEnd, handleProjectDragCancel, - handleNewThread, - archiveThread, - deleteThread, - sortedProjects, - expandedThreadListsByProject, - activeRouteProjectKey, - routeThreadKey, - newThreadShortcutLabel, - threadJumpLabelByKey, attachThreadListAutoAnimateRef, - expandThreadListForProject, - collapseThreadListForProject, dragInProgressRef, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, attachProjectListAutoAnimateRef, projectsLength, } = props; + const sortedProjectKeys = useSidebarProjectKeys(); const handleProjectSortOrderChange = useCallback( (sortOrder: SidebarProjectSortOrder) => { @@ -2252,26 +2397,15 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( > project.projectKey)} + items={[...sortedProjectKeys]} strategy={verticalListSortingStrategy} > - {sortedProjects.map((project) => ( - + {sortedProjectKeys.map((projectKey) => ( + {(dragHandleProps) => ( ) : ( - {sortedProjects.map((project) => ( + {sortedProjectKeys.map((projectKey) => ( store.activeEnvironmentId); - const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const reorderProjects = useUiStateStore((store) => store.reorderProjects); - const navigate = useNavigate(); - const pathname = useLocation({ select: (loc) => loc.pathname }); - const isOnSettings = pathname.startsWith("/settings"); - const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); - const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); - const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); - const { updateSettings } = useUpdateSettings(); - const { handleNewThread } = useNewThreadHandler(); - const { archiveThread, deleteThread } = useThreadActions(); +function useSidebarKeyboardController(props: { + sortedProjectKeys: readonly string[]; + sidebarThreadSortSnapshots: readonly SidebarThreadSortSnapshot[]; + physicalToLogicalKey: ReadonlyMap; + expandedThreadListsByProject: ReadonlySet; + routeThreadRef: ScopedThreadRef | null; + routeThreadKey: string | null; + platform: string; + keybindings: ReturnType; + navigateToThread: (threadRef: ScopedThreadRef) => void; + sidebarThreadSortOrder: SidebarThreadSortOrder; +}) { + const { + sortedProjectKeys, + sidebarThreadSortSnapshots, + physicalToLogicalKey, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + sidebarThreadSortOrder, + } = props; + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const sidebarThreadSnapshotByKey = useMemo( + () => + new Map( + sidebarThreadSortSnapshots.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [sidebarThreadSortSnapshots], + ); + const threadsByProjectKey = useMemo(() => { + const next = new Map(); + for (const thread of sidebarThreadSortSnapshots) { + const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const existing = next.get(logicalKey); + if (existing) { + existing.push(thread); + } else { + next.set(logicalKey, [thread]); + } + } + return next; + }, [physicalToLogicalKey, sidebarThreadSortSnapshots]); + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjectKeys.flatMap((projectKey) => { + const projectThreads = sortThreadsForSidebar( + (threadsByProjectKey.get(projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + ); + const projectExpanded = projectExpandedById[projectKey] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeThreadKey, + ) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); + const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const previewThreads = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreads + : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + }), + [ + expandedThreadListsByProject, + projectExpandedById, + routeThreadKey, + sidebarThreadSortOrder, + sortedProjectKeys, + threadsByProjectKey, + ], + ); + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(threadKey, jumpCommand); + } + + return mapping; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }), + [routeThreadRef], + ); + const threadJumpLabelByKey = useMemo( + () => + showThreadJumpHints + ? buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: getCurrentSidebarShortcutContext().terminalOpen, + threadJumpCommandByKey, + }) + : EMPTY_THREAD_JUMP_LABELS, + [ + getCurrentSidebarShortcutContext, + keybindings, + platform, + showThreadJumpHints, + threadJumpCommandByKey, + ], + ); + const threadJumpLabelsRef = useRef>(threadJumpLabelByKey); + threadJumpLabelsRef.current = threadJumpLabelByKey; + const showThreadJumpHintsRef = useRef(showThreadJumpHints); + showThreadJumpHintsRef.current = showThreadJumpHints; + + useEffect(() => { + const clearThreadJumpHints = () => { + updateThreadJumpHintsVisibility(false); + }; + const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + event.key !== "Meta" && + event.key !== "Control" && + event.key !== "Alt" && + event.key !== "Shift" && + !showThreadJumpHintsRef.current && + threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + if ( + showThreadJumpHintsRef.current || + threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS + ) { + clearThreadJumpHints(); + } + } else { + updateThreadJumpHintsVisibility(true); + } + + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: shortcutContext, + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: visibleSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }); + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + }; + + const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + clearThreadJumpHints(); + return; + } + updateThreadJumpHintsVisibility(true); + }; + + const onWindowBlur = () => { + clearThreadJumpHints(); + }; + + window.addEventListener("keydown", onWindowKeyDown); + window.addEventListener("keyup", onWindowKeyUp); + window.addEventListener("blur", onWindowBlur); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + window.removeEventListener("keyup", onWindowKeyUp); + window.removeEventListener("blur", onWindowBlur); + }; + }, [ + getCurrentSidebarShortcutContext, + keybindings, + navigateToThread, + platform, + routeThreadKey, + sidebarThreadSnapshotByKey, + threadJumpCommandByKey, + threadJumpThreadKeys, + updateThreadJumpHintsVisibility, + visibleSidebarThreadKeys, + ]); + + return threadJumpLabelByKey; +} + +const SidebarProjectOrderingController = memo(function SidebarProjectOrderingController(props: { + sidebarProjects: readonly SidebarProjectSnapshot[]; + physicalToLogicalKey: ReadonlyMap; + sidebarProjectSortOrder: SidebarProjectSortOrder; +}) { + const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; + const sidebarThreadSortSnapshots = useStore( + useMemo(() => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(), []), + ); + const sortedProjectKeys = useMemo(() => { + if (sidebarProjectSortOrder === "manual") { + return sidebarProjects.map((project) => project.projectKey); + } + + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = sidebarThreadSortSnapshots + .filter((thread) => thread.archivedAt === null) + .map((thread) => { + const physicalKey = scopedProjectKey( + scopeProjectRef(thread.environmentId, thread.projectId), + ); + return { + id: thread.id, + environmentId: thread.environmentId, + projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + latestUserMessageAt: thread.latestUserMessageAt, + }; + }); + return sortProjectsForSidebar(sortableProjects, sortableThreads, sidebarProjectSortOrder).map( + (project) => project.id, + ); + }, [sidebarProjectSortOrder, sidebarProjects, sidebarThreadSortSnapshots, physicalToLogicalKey]); + + useEffect(() => { + const currentState = sidebarTransientStore.getState(); + if (stringsEqual(currentState.sortedProjectKeys, sortedProjectKeys)) { + return; + } + + sidebarTransientStore.setState({ + sortedProjectKeys, + }); + }, [sortedProjectKeys]); + + return null; +}); + +const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { + navigateToThread: (threadRef: ScopedThreadRef) => void; + sidebarThreadSortOrder: SidebarThreadSortOrder; +}) { + const { navigateToThread, sidebarThreadSortOrder } = props; + const sortedProjectKeys = useSidebarProjectKeys(); + const physicalToLogicalKey = useSidebarPhysicalToLogicalKey(); + const expandedThreadListsByProject = useZustandStore( + sidebarTransientStore, + (state) => state.expandedThreadListsByProject, + ); + const sidebarThreadSortSnapshots = useStore( + useMemo(() => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(), []), + ); const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const activeRouteProjectKey = useMemo(() => { + if (!routeThreadRef) { + return null; + } + + const activeThread = sidebarThreadSortSnapshots.find( + (thread) => + thread.environmentId === routeThreadRef.environmentId && + thread.id === routeThreadRef.threadId, + ); + if (!activeThread) { + return null; + } + + const physicalKey = scopedProjectKey( + scopeProjectRef(activeThread.environmentId, activeThread.projectId), + ); + return physicalToLogicalKey.get(physicalKey) ?? physicalKey; + }, [physicalToLogicalKey, routeThreadRef, sidebarThreadSortSnapshots]); const keybindings = useServerKeybindings(); + const platform = navigator.platform; + const threadJumpLabelByKey = useSidebarKeyboardController({ + sortedProjectKeys, + sidebarThreadSortSnapshots, + physicalToLogicalKey, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + sidebarThreadSortOrder, + }); + + useEffect(() => { + const currentState = sidebarTransientStore.getState(); + if ( + currentState.activeRouteThreadKey === routeThreadKey && + currentState.activeRouteProjectKey === activeRouteProjectKey && + currentState.threadJumpLabelByKey === threadJumpLabelByKey + ) { + return; + } + + sidebarTransientStore.setState({ + activeRouteThreadKey: routeThreadKey, + activeRouteProjectKey, + threadJumpLabelByKey, + }); + }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); + + return null; +}); + +export default function Sidebar() { + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const navigate = useNavigate(); + const isOnSettings = useLocation({ select: (loc) => loc.pathname.startsWith("/settings") }); + const settingsPathname = useLocation({ + select: (loc) => (loc.pathname.startsWith("/settings") ? loc.pathname : "/settings"), + }); + const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); + const { updateSettings } = useUpdateSettings(); + const { handleNewThread } = useNewThreadHandler(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); - const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< - ReadonlySet - >(() => new Set()); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); @@ -2364,7 +2877,6 @@ export default function Sidebar() { const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); - const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -2390,6 +2902,9 @@ export default function Sidebar() { return mapping; }, [orderedProjects]); + const previousSidebarProjectSnapshotByKeyRef = useRef< + ReadonlyMap + >(EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY); const sidebarProjects = useMemo(() => { // Group projects by logical key while preserving insertion order from // orderedProjects. @@ -2404,6 +2919,8 @@ export default function Sidebar() { } } + const previousSidebarProjectSnapshotByKey = previousSidebarProjectSnapshotByKeyRef.current; + const nextSidebarProjectSnapshotByKey = new Map(); const result: SidebarProjectSnapshot[] = []; const seen = new Set(); for (const project of orderedProjects) { @@ -2434,109 +2951,60 @@ export default function Sidebar() { const saved = savedEnvironmentRegistry[p.environmentId]; return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; }); - const snapshot: SidebarProjectSnapshot = { + const nextSnapshot: SidebarProjectSnapshot = { id: representative.id, environmentId: representative.environmentId, name: representative.name, cwd: representative.cwd, repositoryIdentity: representative.repositoryIdentity ?? null, defaultModelSelection: representative.defaultModelSelection, - createdAt: representative.createdAt, - updatedAt: representative.updatedAt, - scripts: representative.scripts, - projectKey: logicalKey, - environmentPresence: - hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", - memberProjectRefs: refs, - remoteEnvironmentLabels: remoteLabels, - }; - result.push(snapshot); - } - return result; - }, [ - orderedProjects, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); - - const sidebarProjectByKey = useMemo( - () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), - [sidebarProjects], - ); - const sidebarThreadByKey = useMemo( - () => - new Map( - sidebarThreads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), - [sidebarThreads], - ); - // Resolve the active route's project key to a logical key so it matches the - // sidebar's grouped project entries. - const activeRouteProjectKey = useMemo(() => { - if (!routeThreadKey) { - return null; - } - const activeThread = sidebarThreadByKey.get(routeThreadKey); - if (!activeThread) return null; - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); - return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); - - // Group threads by logical project key so all threads from grouped projects - // are displayed together. - const threadsByProjectKey = useMemo(() => { - const next = new Map(); - for (const thread of sidebarThreads) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const existing = next.get(logicalKey); - if (existing) { - existing.push(thread); - } else { - next.set(logicalKey, [thread]); - } + createdAt: representative.createdAt, + updatedAt: representative.updatedAt, + scripts: representative.scripts, + projectKey: logicalKey, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjectRefs: refs, + remoteEnvironmentLabels: remoteLabels, + }; + const snapshot = sidebarProjectSnapshotsEqual( + previousSidebarProjectSnapshotByKey.get(logicalKey), + nextSnapshot, + ) + ? (previousSidebarProjectSnapshotByKey.get(logicalKey) as SidebarProjectSnapshot) + : nextSnapshot; + nextSidebarProjectSnapshotByKey.set(logicalKey, snapshot); + result.push(snapshot); } - return next; - }, [sidebarThreads, physicalToLogicalKey]); - const getCurrentSidebarShortcutContext = useCallback( - () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - }), - [routeThreadRef], - ); - const newThreadShortcutLabelOptions = useMemo( - () => ({ - platform, - context: { - terminalFocus: false, - terminalOpen: false, - }, - }), - [platform], + previousSidebarProjectSnapshotByKeyRef.current = + nextSidebarProjectSnapshotByKey.size === 0 + ? EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY + : nextSidebarProjectSnapshotByKey; + return result; + }, [ + orderedProjects, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + + const sidebarProjectByKey = useMemo( + () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + [sidebarProjects], ); - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); const focusMostRecentThreadForProject = useCallback( (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { const physicalKey = scopedProjectKey( scopeProjectRef(projectRef.environmentId, projectRef.projectId), ); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const memberProjectRefs = sidebarProjectByKey.get(logicalKey)?.memberProjectRefs ?? [ + projectRef, + ]; const latestThread = sortThreadsForSidebar( - (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), + selectSidebarThreadsForProjectRefs(useStore.getState(), memberProjectRefs).filter( + (thread) => thread.archivedAt === null, + ), sidebarThreadSortOrder, )[0]; if (!latestThread) return; @@ -2546,7 +3014,7 @@ export default function Sidebar() { params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), }); }, - [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], + [navigate, physicalToLogicalKey, sidebarProjectByKey, sidebarThreadSortOrder], ); const addProjectFromPath = useCallback( @@ -2734,254 +3202,22 @@ export default function Sidebar() { animatedThreadListsRef.current.add(node); }, []); - const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], - ); - const sortedProjects = useMemo(() => { - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - const sortableThreads = visibleThreads.map((thread) => { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - return { - ...thread, - projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, - }; - }); - return sortProjectsForSidebar( - sortableProjects, - sortableThreads, - sidebarProjectSortOrder, - ).flatMap((project) => { - const resolvedProject = sidebarProjectByKey.get(project.id); - return resolvedProject ? [resolvedProject] : []; - }); - }, [ - sidebarProjectSortOrder, - physicalToLogicalKey, - sidebarProjectByKey, - sidebarProjects, - visibleThreads, - ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - const visibleSidebarThreadKeys = useMemo( - () => - sortedProjects.flatMap((project) => { - const projectThreads = sortThreadsForSidebar( - (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, - ), - sidebarThreadSortOrder, - ); - const projectExpanded = projectExpandedById[project.projectKey] ?? true; - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - }), - [ - sidebarThreadSortOrder, - expandedThreadListsByProject, - projectExpandedById, - routeThreadKey, - sortedProjects, - threadsByProjectKey, - ], - ); - const threadJumpCommandByKey = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - return mapping; - } - mapping.set(threadKey, jumpCommand); - } - - return mapping; - }, [visibleSidebarThreadKeys]); - const threadJumpThreadKeys = useMemo( - () => [...threadJumpCommandByKey.keys()], - [threadJumpCommandByKey], - ); - const [threadJumpLabelByKey, setThreadJumpLabelByKey] = - useState>(EMPTY_THREAD_JUMP_LABELS); - const threadJumpLabelsRef = useRef>(EMPTY_THREAD_JUMP_LABELS); - threadJumpLabelsRef.current = threadJumpLabelByKey; - const showThreadJumpHintsRef = useRef(showThreadJumpHints); - showThreadJumpHintsRef.current = showThreadJumpHints; - const visibleThreadJumpLabelByKey = showThreadJumpHints - ? threadJumpLabelByKey - : EMPTY_THREAD_JUMP_LABELS; - const orderedSidebarThreadKeys = visibleSidebarThreadKeys; useEffect(() => { - const clearThreadJumpHints = () => { - setThreadJumpLabelByKey((current) => - current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, - ); - updateThreadJumpHintsVisibility(false); - }; - const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - event.key !== "Meta" && - event.key !== "Control" && - event.key !== "Alt" && - event.key !== "Shift" && - !showThreadJumpHintsRef.current && - threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; - - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - if ( - showThreadJumpHintsRef.current || - threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS - ) { - clearThreadJumpHints(); - } - } else { - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - } - - if (event.defaultPrevented || event.repeat) { - return; - } - - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, - }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return; - } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - }; - - const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - clearThreadJumpHints(); - return; - } - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - }; - - const onWindowBlur = () => { - clearThreadJumpHints(); - }; - - window.addEventListener("keydown", onWindowKeyDown); - window.addEventListener("keyup", onWindowKeyUp); - window.addEventListener("blur", onWindowBlur); + const currentState = sidebarTransientStore.getState(); + if ( + currentState.projectSnapshotByKey === sidebarProjectByKey && + currentState.physicalToLogicalKey === physicalToLogicalKey + ) { + return; + } - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); - }; - }, [ - getCurrentSidebarShortcutContext, - keybindings, - navigateToThread, - orderedSidebarThreadKeys, - platform, - routeThreadKey, - sidebarThreadByKey, - threadJumpCommandByKey, - threadJumpThreadKeys, - updateThreadJumpHintsVisibility, - ]); + sidebarTransientStore.setState({ + projectSnapshotByKey: sidebarProjectByKey, + physicalToLogicalKey, + }); + }, [physicalToLogicalKey, sidebarProjectByKey]); useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { @@ -3102,30 +3338,21 @@ export default function Sidebar() { } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - const expandThreadListForProject = useCallback((projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (current.has(projectKey)) return current; - const next = new Set(current); - next.add(projectKey); - return next; - }); - }, []); - - const collapseThreadListForProject = useCallback((projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (!current.has(projectKey)) return current; - const next = new Set(current); - next.delete(projectKey); - return next; - }); - }, []); - return ( <> + + {isOnSettings ? ( - + ) : ( <> value === right[index]); +} + +export interface ProjectThreadStatusInput { + threadKey: string; + hasActionableProposedPlan: boolean; + hasPendingApprovals: boolean; + hasPendingUserInput: boolean; + interactionMode: SidebarThreadSummary["interactionMode"]; + latestTurn: SidebarThreadSummary["latestTurn"]; + session: SidebarThreadSummary["session"]; +} + +export interface SidebarThreadRowSnapshot { + id: ThreadId; + environmentId: EnvironmentId; + projectId: ProjectId; + title: string; + branch: string | null; + worktreePath: string | null; +} + +interface ProjectThreadRenderEntry { + threadKey: string; + id: ThreadId; + environmentId: EnvironmentId; + projectId: ProjectId; + createdAt: string; + archivedAt: string | null; + updatedAt?: string | undefined; + latestUserMessageAt: string | null; +} + +export interface SidebarProjectRenderStateSnapshot { + hasOverflowingThreads: boolean; + hiddenThreadKeys: readonly string[]; + renderedThreadKeys: readonly string[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; +} + +const EMPTY_PROJECT_RENDER_STATE: SidebarProjectRenderStateSnapshot = { + hasOverflowingThreads: false, + hiddenThreadKeys: EMPTY_PROJECT_THREAD_KEYS, + renderedThreadKeys: EMPTY_PROJECT_THREAD_KEYS, + showEmptyThreadState: false, + shouldShowThreadPanel: false, +}; + +function collectProjectThreadEntries( + state: AppState, + memberProjectRefs: readonly ScopedProjectRef[], +): ProjectThreadRenderEntry[] { + if (memberProjectRefs.length === 0) { + return []; + } + + const entries: ProjectThreadRenderEntry[] = []; + for (const ref of memberProjectRefs) { + const environmentState = state.environmentStateById[ref.environmentId]; + if (!environmentState) { + continue; + } + const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? []; + for (const threadId of threadIds) { + const summary = environmentState.sidebarThreadSummaryById[threadId]; + if (!summary) { + continue; + } + entries.push({ + threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), + id: summary.id, + environmentId: summary.environmentId, + projectId: summary.projectId, + createdAt: summary.createdAt, + archivedAt: summary.archivedAt, + updatedAt: summary.updatedAt, + latestUserMessageAt: summary.latestUserMessageAt, + }); + } + } + return entries; +} + +function collectProjectThreadStatusInputs( + state: AppState, + memberProjectRefs: readonly ScopedProjectRef[], +): ProjectThreadStatusInput[] { + if (memberProjectRefs.length === 0) { + return []; + } + + const inputs: ProjectThreadStatusInput[] = []; + for (const ref of memberProjectRefs) { + const environmentState = state.environmentStateById[ref.environmentId]; + if (!environmentState) { + continue; + } + const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? []; + for (const threadId of threadIds) { + const summary = environmentState.sidebarThreadSummaryById[threadId]; + if (!summary) { + continue; + } + inputs.push({ + threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), + hasActionableProposedPlan: summary.hasActionableProposedPlan, + hasPendingApprovals: summary.hasPendingApprovals, + hasPendingUserInput: summary.hasPendingUserInput, + interactionMode: summary.interactionMode, + latestTurn: summary.latestTurn, + session: summary.session, + }); + } + } + return inputs; +} + +function projectThreadStatusInputsEqual( + left: ProjectThreadStatusInput | undefined, + right: ProjectThreadStatusInput | undefined, +): boolean { + return ( + left !== undefined && + right !== undefined && + left.threadKey === right.threadKey && + left.hasActionableProposedPlan === right.hasActionableProposedPlan && + left.hasPendingApprovals === right.hasPendingApprovals && + left.hasPendingUserInput === right.hasPendingUserInput && + left.interactionMode === right.interactionMode && + left.latestTurn?.startedAt === right.latestTurn?.startedAt && + left.latestTurn?.completedAt === right.latestTurn?.completedAt && + left.session?.orchestrationStatus === right.session?.orchestrationStatus && + left.session?.activeTurnId === right.session?.activeTurnId && + left.session?.status === right.session?.status + ); +} + +export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(): ( + state: AppState, +) => SidebarThreadSortSnapshot[] { + let previousResult = EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS; + let previousEntries = new Map(); + + return (state) => { + const nextEntries = new Map(); + const nextResult: SidebarThreadSortSnapshot[] = []; + let changed = false; + + for (const [environmentId, environmentState] of Object.entries( + state.environmentStateById, + ) as Array<[EnvironmentId, EnvironmentState]>) { + for (const threadId of environmentState.threadIds) { + const summary = environmentState.sidebarThreadSummaryById[threadId]; + if (!summary || summary.environmentId !== environmentId) { + continue; + } + + const entryKey = `${environmentId}:${threadId}`; + const previousEntry = previousEntries.get(entryKey); + if ( + previousEntry && + previousEntry.id === summary.id && + previousEntry.environmentId === summary.environmentId && + previousEntry.projectId === summary.projectId && + previousEntry.createdAt === summary.createdAt && + previousEntry.archivedAt === summary.archivedAt && + previousEntry.updatedAt === summary.updatedAt && + previousEntry.latestUserMessageAt === summary.latestUserMessageAt + ) { + nextEntries.set(entryKey, previousEntry); + nextResult.push(previousEntry); + if (previousResult[nextResult.length - 1] !== previousEntry) { + changed = true; + } + continue; + } + + const snapshot: SidebarThreadSortSnapshot = { + id: summary.id, + environmentId: summary.environmentId, + projectId: summary.projectId, + createdAt: summary.createdAt, + archivedAt: summary.archivedAt, + updatedAt: summary.updatedAt, + latestUserMessageAt: summary.latestUserMessageAt, + }; + nextEntries.set(entryKey, snapshot); + nextResult.push(snapshot); + changed = true; + } + } + + if (previousResult.length !== nextResult.length) { + changed = true; + } + + if (!changed) { + previousEntries = nextEntries; + return previousResult; + } + + previousEntries = nextEntries; + previousResult = nextResult.length === 0 ? EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS : nextResult; + return previousResult; + }; +} + +export function createSidebarThreadRowSnapshotSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => SidebarThreadRowSnapshot | undefined { + let previousResult: SidebarThreadRowSnapshot | undefined; + + return (state) => { + if (!ref) { + return undefined; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + return undefined; + } + + const nextResult: SidebarThreadRowSnapshot = { + id: summary.id, + environmentId: summary.environmentId, + projectId: summary.projectId, + title: summary.title, + branch: summary.branch, + worktreePath: summary.worktreePath ?? null, + }; + + if ( + previousResult && + previousResult.id === nextResult.id && + previousResult.environmentId === nextResult.environmentId && + previousResult.projectId === nextResult.projectId && + previousResult.title === nextResult.title && + previousResult.branch === nextResult.branch && + previousResult.worktreePath === nextResult.worktreePath + ) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarThreadStatusInputSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => ProjectThreadStatusInput | undefined { + let previousResult: ProjectThreadStatusInput | undefined; + + return (state) => { + if (!ref) { + return undefined; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + return undefined; + } + + const nextResult: ProjectThreadStatusInput = { + threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), + hasActionableProposedPlan: summary.hasActionableProposedPlan, + hasPendingApprovals: summary.hasPendingApprovals, + hasPendingUserInput: summary.hasPendingUserInput, + interactionMode: summary.interactionMode, + latestTurn: summary.latestTurn, + session: summary.session, + }; + + if (projectThreadStatusInputsEqual(previousResult, nextResult)) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarProjectRenderStateSelector(input: { + activeRouteThreadKey: string | null; + isThreadListExpanded: boolean; + memberProjectRefs: readonly ScopedProjectRef[]; + projectExpanded: boolean; + previewLimit: number; + threadSortOrder: SidebarThreadSortOrder; +}): (state: AppState) => SidebarProjectRenderStateSnapshot { + let previousResult = EMPTY_PROJECT_RENDER_STATE; + + return (state) => { + const visibleProjectThreads = sortThreadsForSidebar( + collectProjectThreadEntries(state, input.memberProjectRefs).filter( + (thread) => thread.archivedAt === null, + ), + input.threadSortOrder, + ); + const pinnedCollapsedThread = + !input.projectExpanded && input.activeRouteThreadKey + ? (visibleProjectThreads.find( + (thread) => thread.threadKey === input.activeRouteThreadKey, + ) ?? null) + : null; + const shouldShowThreadPanel = input.projectExpanded || pinnedCollapsedThread !== null; + const hasOverflowingThreads = visibleProjectThreads.length > input.previewLimit; + const previewThreads = + input.isThreadListExpanded || !hasOverflowingThreads + ? visibleProjectThreads + : visibleProjectThreads.slice(0, input.previewLimit); + const renderedThreadKeys = pinnedCollapsedThread + ? [pinnedCollapsedThread.threadKey] + : previewThreads.map((thread) => thread.threadKey); + const renderedThreadKeySet = new Set(renderedThreadKeys); + const hiddenThreadKeys = visibleProjectThreads + .filter((thread) => !renderedThreadKeySet.has(thread.threadKey)) + .map((thread) => thread.threadKey); + const nextResult: SidebarProjectRenderStateSnapshot = { + hasOverflowingThreads, + hiddenThreadKeys: + hiddenThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : hiddenThreadKeys, + renderedThreadKeys: + renderedThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : renderedThreadKeys, + showEmptyThreadState: input.projectExpanded && visibleProjectThreads.length === 0, + shouldShowThreadPanel, + }; + + if ( + previousResult.hasOverflowingThreads === nextResult.hasOverflowingThreads && + previousResult.showEmptyThreadState === nextResult.showEmptyThreadState && + previousResult.shouldShowThreadPanel === nextResult.shouldShowThreadPanel && + stringArraysEqual(previousResult.renderedThreadKeys, nextResult.renderedThreadKeys) && + stringArraysEqual(previousResult.hiddenThreadKeys, nextResult.hiddenThreadKeys) + ) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarProjectThreadStatusInputsSelector( + memberProjectRefs: readonly ScopedProjectRef[], +): (state: AppState) => readonly ProjectThreadStatusInput[] { + let previousResult: readonly ProjectThreadStatusInput[] = EMPTY_PROJECT_THREAD_STATUS_INPUTS; + + return (state) => { + const nextInputs = collectProjectThreadStatusInputs(state, memberProjectRefs); + if ( + previousResult.length === nextInputs.length && + previousResult.every((previousInput, index) => { + const nextInput = nextInputs[index]; + return nextInput !== undefined && projectThreadStatusInputsEqual(previousInput, nextInput); + }) + ) { + return previousResult; + } + + previousResult = nextInputs.length === 0 ? EMPTY_PROJECT_THREAD_STATUS_INPUTS : nextInputs; + return previousResult; + }; +} From 77aef3246fcdeb24de09865c245134b833e59990 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:51:19 +0100 Subject: [PATCH 2/9] Polish sidebar atomic selectors and controllers --- apps/web/src/components/Sidebar.logic.ts | 27 +- apps/web/src/components/Sidebar.tsx | 657 +----------------- .../components/sidebar/sidebarConstants.ts | 1 + .../components/sidebar/sidebarControllers.tsx | 471 +++++++++++++ .../components/sidebar/sidebarSelectors.ts | 78 ++- .../components/sidebar/sidebarViewStore.ts | 222 ++++++ apps/web/src/logicalProject.ts | 6 +- 7 files changed, 806 insertions(+), 656 deletions(-) create mode 100644 apps/web/src/components/sidebar/sidebarConstants.ts create mode 100644 apps/web/src/components/sidebar/sidebarControllers.tsx create mode 100644 apps/web/src/components/sidebar/sidebarViewStore.ts diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 6abd16dd62..4dce21cba6 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -14,6 +14,7 @@ type SidebarProject = { updatedAt?: string | undefined; }; type SidebarThreadSortInput = Pick & { + projectId: string; latestUserMessageAt?: string | null; messages?: Pick[]; }; @@ -44,16 +45,28 @@ const THREAD_STATUS_PRIORITY: Record = { type ThreadStatusInput = Pick< SidebarThreadSummary, - | "hasActionableProposedPlan" - | "hasPendingApprovals" - | "hasPendingUserInput" - | "interactionMode" - | "latestTurn" - | "session" + "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" | "interactionMode" > & { + latestTurn: ThreadStatusLatestTurnSnapshot | null; + session: ThreadStatusSessionSnapshot | null; lastVisitedAt?: string | undefined; }; +type LatestTurnSnapshot = NonNullable; +type SessionSnapshot = NonNullable; + +export interface ThreadStatusLatestTurnSnapshot { + turnId: LatestTurnSnapshot["turnId"]; + startedAt: LatestTurnSnapshot["startedAt"]; + completedAt: LatestTurnSnapshot["completedAt"]; +} + +export interface ThreadStatusSessionSnapshot { + orchestrationStatus: SessionSnapshot["orchestrationStatus"]; + activeTurnId?: SessionSnapshot["activeTurnId"]; + status: SessionSnapshot["status"]; +} + export interface ThreadJumpHintVisibilityController { sync: (shouldShow: boolean) => void; dispose: () => void; @@ -541,7 +554,7 @@ export function getProjectSortTimestamp( export function sortProjectsForSidebar< TProject extends SidebarProject, - TThread extends Pick & SidebarThreadSortInput, + TThread extends SidebarThreadSortInput, >( projects: readonly TProject[], threads: readonly TThread[], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b260020a1a..4bedf5ce5e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -14,8 +14,6 @@ import { import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; -import { useStore as useZustandStore } from "zustand"; -import { createStore } from "zustand/vanilla"; import { useShallow } from "zustand/react/shallow"; import { DndContext, @@ -37,7 +35,6 @@ import { type DesktopUpdateState, type EnvironmentId, ProjectId, - type ScopedProjectRef, type ScopedThreadRef, type ThreadEnvMode, ThreadId, @@ -50,7 +47,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; -import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, @@ -58,7 +55,6 @@ import { import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; -import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { selectProjectByRef, @@ -71,25 +67,14 @@ import { } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; -import { - resolveShortcutCommand, - shortcutLabelForCommand, - shouldShowThreadJumpHints, - threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, -} from "../keybindings"; +import { shortcutLabelForCommand } from "../keybindings"; import { useGitStatus } from "../lib/gitStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; -import { - buildThreadRouteParams, - resolveThreadRouteRef, - resolveThreadRouteTarget, -} from "../threadRoutes"; +import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -123,7 +108,6 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { - resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, @@ -132,9 +116,7 @@ import { resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, - sortProjectsForSidebar, sortThreadsForSidebar, - useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; import { @@ -142,10 +124,26 @@ import { createSidebarProjectThreadStatusInputsSelector, createSidebarThreadRowSnapshotSelectorByRef, createSidebarThreadStatusInputSelectorByRef, - createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector, type ProjectThreadStatusInput, - type SidebarThreadSortSnapshot, } from "./sidebar/sidebarSelectors"; +import { THREAD_PREVIEW_LIMIT } from "./sidebar/sidebarConstants"; +import { + SidebarKeyboardController, + SidebarProjectOrderingController, +} from "./sidebar/sidebarControllers"; +import { + collapseSidebarProjectThreadList, + expandSidebarProjectThreadList, + sidebarProjectSnapshotsEqual, + syncSidebarProjectMappings, + useSidebarIsActiveThread, + useSidebarProjectActiveRouteThreadKey, + useSidebarProjectKeys, + useSidebarProjectSnapshot, + useSidebarProjectThreadListExpanded, + useSidebarThreadJumpLabel, + type SidebarProjectSnapshot, +} from "./sidebar/sidebarViewStore"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { readEnvironmentApi } from "../environmentApi"; @@ -157,7 +155,6 @@ import { useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import type { Project } from "../types"; -const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -171,194 +168,6 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; -const EMPTY_THREAD_JUMP_LABELS = new Map(); -const EMPTY_SIDEBAR_PROJECT_KEYS: string[] = []; -const EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY = new Map(); -const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); - -interface SidebarTransientState { - sortedProjectKeys: readonly string[]; - projectSnapshotByKey: ReadonlyMap; - physicalToLogicalKey: ReadonlyMap; - activeRouteThreadKey: string | null; - activeRouteProjectKey: string | null; - threadJumpLabelByKey: ReadonlyMap; - expandedThreadListsByProject: ReadonlySet; -} - -const sidebarTransientStore = createStore(() => ({ - sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, - projectSnapshotByKey: EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY, - physicalToLogicalKey: new Map(), - activeRouteThreadKey: null, - activeRouteProjectKey: null, - threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, - expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, -})); - -function useSidebarProjectKeys(): readonly string[] { - return useZustandStore(sidebarTransientStore, (state) => state.sortedProjectKeys); -} - -function useSidebarPhysicalToLogicalKey(): ReadonlyMap { - return useZustandStore(sidebarTransientStore, (state) => state.physicalToLogicalKey); -} - -function useSidebarProjectSnapshot(projectKey: string): SidebarProjectSnapshot | null { - return useZustandStore( - sidebarTransientStore, - useCallback( - (state: SidebarTransientState) => state.projectSnapshotByKey.get(projectKey) ?? null, - [projectKey], - ), - ); -} - -function useSidebarProjectThreadListExpanded(projectKey: string): boolean { - return useZustandStore( - sidebarTransientStore, - useCallback( - (state: SidebarTransientState) => state.expandedThreadListsByProject.has(projectKey), - [projectKey], - ), - ); -} - -function expandSidebarProjectThreadList(projectKey: string): void { - const { expandedThreadListsByProject } = sidebarTransientStore.getState(); - if (expandedThreadListsByProject.has(projectKey)) { - return; - } - - sidebarTransientStore.setState({ - expandedThreadListsByProject: new Set([...expandedThreadListsByProject, projectKey]), - }); -} - -function collapseSidebarProjectThreadList(projectKey: string): void { - const { expandedThreadListsByProject } = sidebarTransientStore.getState(); - if (!expandedThreadListsByProject.has(projectKey)) { - return; - } - - const nextExpandedThreadListsByProject = new Set(expandedThreadListsByProject); - nextExpandedThreadListsByProject.delete(projectKey); - sidebarTransientStore.setState({ - expandedThreadListsByProject: - nextExpandedThreadListsByProject.size === 0 - ? EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT - : nextExpandedThreadListsByProject, - }); -} - -function useSidebarIsActiveThread(threadKey: string): boolean { - return useZustandStore( - sidebarTransientStore, - useCallback( - (state: SidebarTransientState) => state.activeRouteThreadKey === threadKey, - [threadKey], - ), - ); -} - -function useSidebarThreadJumpLabel(threadKey: string): string | null { - return useZustandStore( - sidebarTransientStore, - useCallback( - (state: SidebarTransientState) => state.threadJumpLabelByKey.get(threadKey) ?? null, - [threadKey], - ), - ); -} - -function useSidebarProjectActiveRouteThreadKey(projectKey: string): string | null { - return useZustandStore( - sidebarTransientStore, - useCallback( - (state: SidebarTransientState) => { - return state.activeRouteProjectKey === projectKey ? state.activeRouteThreadKey : null; - }, - [projectKey], - ), - ); -} - -function buildThreadJumpLabelMap(input: { - keybindings: ReturnType; - platform: string; - terminalOpen: boolean; - threadJumpCommandByKey: ReadonlyMap< - string, - NonNullable> - >; -}): ReadonlyMap { - if (input.threadJumpCommandByKey.size === 0) { - return EMPTY_THREAD_JUMP_LABELS; - } - - const shortcutLabelOptions = { - platform: input.platform, - context: { - terminalFocus: false, - terminalOpen: input.terminalOpen, - }, - } as const; - const mapping = new Map(); - for (const [threadKey, command] of input.threadJumpCommandByKey) { - const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); - if (label) { - mapping.set(threadKey, label); - } - } - return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; -} - -type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; - -type SidebarProjectSnapshot = Project & { - projectKey: string; - environmentPresence: EnvironmentPresence; - memberProjectRefs: readonly ScopedProjectRef[]; - /** Labels for remote environments this project lives in. */ - remoteEnvironmentLabels: readonly string[]; -}; - -function refsEqual(left: readonly ScopedProjectRef[], right: readonly ScopedProjectRef[]): boolean { - return ( - left.length === right.length && - left.every( - (ref, index) => - ref.environmentId === right[index]?.environmentId && - ref.projectId === right[index]?.projectId, - ) - ); -} - -function stringsEqual(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - -function sidebarProjectSnapshotsEqual( - left: SidebarProjectSnapshot | undefined, - right: SidebarProjectSnapshot, -): boolean { - return ( - left !== undefined && - left.id === right.id && - left.environmentId === right.environmentId && - left.name === right.name && - left.cwd === right.cwd && - left.repositoryIdentity === right.repositoryIdentity && - left.defaultModelSelection === right.defaultModelSelection && - left.createdAt === right.createdAt && - left.updatedAt === right.updatedAt && - left.scripts === right.scripts && - left.projectKey === right.projectKey && - left.environmentPresence === right.environmentPresence && - refsEqual(left.memberProjectRefs, right.memberProjectRefs) && - stringsEqual(left.remoteEnvironmentLabels, right.remoteEnvironmentLabels) - ); -} interface TerminalStatusIndicator { label: "Terminal process running"; @@ -1055,14 +864,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP if (isShiftClick) { event.preventDefault(); - const projectSnapshot = sidebarTransientStore - .getState() - .projectSnapshotByKey.get(projectKey); - const orderedProjectThreadKeys = projectSnapshot + const orderedProjectThreadKeys = project ? sortThreadsForSidebar( selectSidebarThreadsForProjectRefs( useStore.getState(), - projectSnapshot.memberProjectRefs, + project.memberProjectRefs, ).filter((projectThread) => projectThread.archivedAt === null), threadSortOrder, ).map((projectThread) => @@ -1081,7 +887,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP [ clearSelection, navigateToThread, - projectKey, + project, rangeSelectTo, threadKey, threadRef, @@ -2447,407 +2253,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ); }); -function useSidebarKeyboardController(props: { - sortedProjectKeys: readonly string[]; - sidebarThreadSortSnapshots: readonly SidebarThreadSortSnapshot[]; - physicalToLogicalKey: ReadonlyMap; - expandedThreadListsByProject: ReadonlySet; - routeThreadRef: ScopedThreadRef | null; - routeThreadKey: string | null; - platform: string; - keybindings: ReturnType; - navigateToThread: (threadRef: ScopedThreadRef) => void; - sidebarThreadSortOrder: SidebarThreadSortOrder; -}) { - const { - sortedProjectKeys, - sidebarThreadSortSnapshots, - physicalToLogicalKey, - expandedThreadListsByProject, - routeThreadRef, - routeThreadKey, - platform, - keybindings, - navigateToThread, - sidebarThreadSortOrder, - } = props; - const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const sidebarThreadSnapshotByKey = useMemo( - () => - new Map( - sidebarThreadSortSnapshots.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), - [sidebarThreadSortSnapshots], - ); - const threadsByProjectKey = useMemo(() => { - const next = new Map(); - for (const thread of sidebarThreadSortSnapshots) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const existing = next.get(logicalKey); - if (existing) { - existing.push(thread); - } else { - next.set(logicalKey, [thread]); - } - } - return next; - }, [physicalToLogicalKey, sidebarThreadSortSnapshots]); - const visibleSidebarThreadKeys = useMemo( - () => - sortedProjectKeys.flatMap((projectKey) => { - const projectThreads = sortThreadsForSidebar( - (threadsByProjectKey.get(projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, - ), - sidebarThreadSortOrder, - ); - const projectExpanded = projectExpandedById[projectKey] ?? true; - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); - const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - }), - [ - expandedThreadListsByProject, - projectExpandedById, - routeThreadKey, - sidebarThreadSortOrder, - sortedProjectKeys, - threadsByProjectKey, - ], - ); - const threadJumpCommandByKey = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - return mapping; - } - mapping.set(threadKey, jumpCommand); - } - - return mapping; - }, [visibleSidebarThreadKeys]); - const threadJumpThreadKeys = useMemo( - () => [...threadJumpCommandByKey.keys()], - [threadJumpCommandByKey], - ); - const getCurrentSidebarShortcutContext = useCallback( - () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - }), - [routeThreadRef], - ); - const threadJumpLabelByKey = useMemo( - () => - showThreadJumpHints - ? buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: getCurrentSidebarShortcutContext().terminalOpen, - threadJumpCommandByKey, - }) - : EMPTY_THREAD_JUMP_LABELS, - [ - getCurrentSidebarShortcutContext, - keybindings, - platform, - showThreadJumpHints, - threadJumpCommandByKey, - ], - ); - const threadJumpLabelsRef = useRef>(threadJumpLabelByKey); - threadJumpLabelsRef.current = threadJumpLabelByKey; - const showThreadJumpHintsRef = useRef(showThreadJumpHints); - showThreadJumpHintsRef.current = showThreadJumpHints; - - useEffect(() => { - const clearThreadJumpHints = () => { - updateThreadJumpHintsVisibility(false); - }; - const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - event.key !== "Meta" && - event.key !== "Control" && - event.key !== "Alt" && - event.key !== "Shift" && - !showThreadJumpHintsRef.current && - threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; - - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - if ( - showThreadJumpHintsRef.current || - threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS - ) { - clearThreadJumpHints(); - } - } else { - updateThreadJumpHintsVisibility(true); - } - - if (event.defaultPrevented || event.repeat) { - return; - } - - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, - }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: visibleSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return; - } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - }; - - const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - clearThreadJumpHints(); - return; - } - updateThreadJumpHintsVisibility(true); - }; - - const onWindowBlur = () => { - clearThreadJumpHints(); - }; - - window.addEventListener("keydown", onWindowKeyDown); - window.addEventListener("keyup", onWindowKeyUp); - window.addEventListener("blur", onWindowBlur); - - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); - }; - }, [ - getCurrentSidebarShortcutContext, - keybindings, - navigateToThread, - platform, - routeThreadKey, - sidebarThreadSnapshotByKey, - threadJumpCommandByKey, - threadJumpThreadKeys, - updateThreadJumpHintsVisibility, - visibleSidebarThreadKeys, - ]); - - return threadJumpLabelByKey; -} - -const SidebarProjectOrderingController = memo(function SidebarProjectOrderingController(props: { - sidebarProjects: readonly SidebarProjectSnapshot[]; - physicalToLogicalKey: ReadonlyMap; - sidebarProjectSortOrder: SidebarProjectSortOrder; -}) { - const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; - const sidebarThreadSortSnapshots = useStore( - useMemo(() => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(), []), - ); - const sortedProjectKeys = useMemo(() => { - if (sidebarProjectSortOrder === "manual") { - return sidebarProjects.map((project) => project.projectKey); - } - - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - const sortableThreads = sidebarThreadSortSnapshots - .filter((thread) => thread.archivedAt === null) - .map((thread) => { - const physicalKey = scopedProjectKey( - scopeProjectRef(thread.environmentId, thread.projectId), - ); - return { - id: thread.id, - environmentId: thread.environmentId, - projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestUserMessageAt: thread.latestUserMessageAt, - }; - }); - return sortProjectsForSidebar(sortableProjects, sortableThreads, sidebarProjectSortOrder).map( - (project) => project.id, - ); - }, [sidebarProjectSortOrder, sidebarProjects, sidebarThreadSortSnapshots, physicalToLogicalKey]); - - useEffect(() => { - const currentState = sidebarTransientStore.getState(); - if (stringsEqual(currentState.sortedProjectKeys, sortedProjectKeys)) { - return; - } - - sidebarTransientStore.setState({ - sortedProjectKeys, - }); - }, [sortedProjectKeys]); - - return null; -}); - -const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { - navigateToThread: (threadRef: ScopedThreadRef) => void; - sidebarThreadSortOrder: SidebarThreadSortOrder; -}) { - const { navigateToThread, sidebarThreadSortOrder } = props; - const sortedProjectKeys = useSidebarProjectKeys(); - const physicalToLogicalKey = useSidebarPhysicalToLogicalKey(); - const expandedThreadListsByProject = useZustandStore( - sidebarTransientStore, - (state) => state.expandedThreadListsByProject, - ); - const sidebarThreadSortSnapshots = useStore( - useMemo(() => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(), []), - ); - const routeThreadRef = useParams({ - strict: false, - select: (params) => resolveThreadRouteRef(params), - }); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const activeRouteProjectKey = useMemo(() => { - if (!routeThreadRef) { - return null; - } - - const activeThread = sidebarThreadSortSnapshots.find( - (thread) => - thread.environmentId === routeThreadRef.environmentId && - thread.id === routeThreadRef.threadId, - ); - if (!activeThread) { - return null; - } - - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); - return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [physicalToLogicalKey, routeThreadRef, sidebarThreadSortSnapshots]); - const keybindings = useServerKeybindings(); - const platform = navigator.platform; - const threadJumpLabelByKey = useSidebarKeyboardController({ - sortedProjectKeys, - sidebarThreadSortSnapshots, - physicalToLogicalKey, - expandedThreadListsByProject, - routeThreadRef, - routeThreadKey, - platform, - keybindings, - navigateToThread, - sidebarThreadSortOrder, - }); - - useEffect(() => { - const currentState = sidebarTransientStore.getState(); - if ( - currentState.activeRouteThreadKey === routeThreadKey && - currentState.activeRouteProjectKey === activeRouteProjectKey && - currentState.threadJumpLabelByKey === threadJumpLabelByKey - ) { - return; - } - - sidebarTransientStore.setState({ - activeRouteThreadKey: routeThreadKey, - activeRouteProjectKey, - threadJumpLabelByKey, - }); - }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); - - return null; -}); - export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); @@ -2904,7 +2309,7 @@ export default function Sidebar() { const previousSidebarProjectSnapshotByKeyRef = useRef< ReadonlyMap - >(EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY); + >(new Map()); const sidebarProjects = useMemo(() => { // Group projects by logical key while preserving insertion order from // orderedProjects. @@ -2978,7 +2383,7 @@ export default function Sidebar() { } previousSidebarProjectSnapshotByKeyRef.current = nextSidebarProjectSnapshotByKey.size === 0 - ? EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY + ? new Map() : nextSidebarProjectSnapshotByKey; return result; }, [ @@ -3205,15 +2610,7 @@ export default function Sidebar() { const isManualProjectSorting = sidebarProjectSortOrder === "manual"; useEffect(() => { - const currentState = sidebarTransientStore.getState(); - if ( - currentState.projectSnapshotByKey === sidebarProjectByKey && - currentState.physicalToLogicalKey === physicalToLogicalKey - ) { - return; - } - - sidebarTransientStore.setState({ + syncSidebarProjectMappings({ projectSnapshotByKey: sidebarProjectByKey, physicalToLogicalKey, }); diff --git a/apps/web/src/components/sidebar/sidebarConstants.ts b/apps/web/src/components/sidebar/sidebarConstants.ts new file mode 100644 index 0000000000..69b80d55f3 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarConstants.ts @@ -0,0 +1 @@ +export const THREAD_PREVIEW_LIMIT = 6; diff --git a/apps/web/src/components/sidebar/sidebarControllers.tsx b/apps/web/src/components/sidebar/sidebarControllers.tsx new file mode 100644 index 0000000000..c098aded95 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarControllers.tsx @@ -0,0 +1,471 @@ +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; +import { useParams } from "@tanstack/react-router"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { isTerminalFocused } from "../../lib/terminalFocus"; +import { resolveThreadRouteRef } from "../../threadRoutes"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHints, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../../keybindings"; +import { selectThreadTerminalState, useTerminalStateStore } from "../../terminalStateStore"; +import { useUiStateStore } from "../../uiStateStore"; +import { + resolveAdjacentThreadId, + sortProjectsForSidebar, + sortThreadsForSidebar, + useThreadJumpHintVisibility, +} from "../Sidebar.logic"; +import { + createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector, + type SidebarThreadSortSnapshot, +} from "./sidebarSelectors"; +import { THREAD_PREVIEW_LIMIT } from "./sidebarConstants"; +import type { LogicalProjectKey } from "../../logicalProject"; +import { + setSidebarKeyboardState, + setSidebarProjectOrdering, + useSidebarExpandedThreadListsByProject, + useSidebarPhysicalToLogicalKey, + useSidebarProjectKeys, + type SidebarProjectSnapshot, +} from "./sidebarViewStore"; +import { useServerKeybindings } from "../../rpc/serverState"; +import { useStore } from "../../store"; + +const EMPTY_THREAD_JUMP_LABELS = new Map(); + +function buildThreadJumpLabelMap(input: { + keybindings: ReturnType; + platform: string; + terminalOpen: boolean; + threadJumpCommandByKey: ReadonlyMap< + string, + NonNullable> + >; +}): ReadonlyMap { + if (input.threadJumpCommandByKey.size === 0) { + return EMPTY_THREAD_JUMP_LABELS; + } + + const shortcutLabelOptions = { + platform: input.platform, + context: { + terminalFocus: false, + terminalOpen: input.terminalOpen, + }, + } as const; + const mapping = new Map(); + for (const [threadKey, command] of input.threadJumpCommandByKey) { + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(threadKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; +} + +function useSidebarKeyboardController(input: { + sortedProjectKeys: readonly LogicalProjectKey[]; + sidebarThreadSortSnapshots: readonly SidebarThreadSortSnapshot[]; + physicalToLogicalKey: ReadonlyMap; + expandedThreadListsByProject: ReadonlySet; + routeThreadRef: ScopedThreadRef | null; + routeThreadKey: string | null; + platform: string; + keybindings: ReturnType; + navigateToThread: (threadRef: ScopedThreadRef) => void; + sidebarThreadSortOrder: SidebarThreadSortOrder; +}) { + const { + sortedProjectKeys, + sidebarThreadSortSnapshots, + physicalToLogicalKey, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + sidebarThreadSortOrder, + } = input; + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const sidebarThreadSnapshotByKey = useMemo( + () => + new Map( + sidebarThreadSortSnapshots.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [sidebarThreadSortSnapshots], + ); + const threadsByProjectKey = useMemo(() => { + const next = new Map(); + for (const thread of sidebarThreadSortSnapshots) { + const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const existing = next.get(logicalKey); + if (existing) { + existing.push(thread); + } else { + next.set(logicalKey, [thread]); + } + } + return next; + }, [physicalToLogicalKey, sidebarThreadSortSnapshots]); + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjectKeys.flatMap((projectKey) => { + const projectThreads = sortThreadsForSidebar( + (threadsByProjectKey.get(projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + ); + const projectExpanded = projectExpandedById[projectKey] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeThreadKey, + ) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); + const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const previewThreads = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreads + : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + }), + [ + expandedThreadListsByProject, + projectExpandedById, + routeThreadKey, + sidebarThreadSortOrder, + sortedProjectKeys, + threadsByProjectKey, + ], + ); + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(threadKey, jumpCommand); + } + + return mapping; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }), + [routeThreadRef], + ); + const threadJumpLabelByKey = useMemo( + () => + showThreadJumpHints + ? buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: getCurrentSidebarShortcutContext().terminalOpen, + threadJumpCommandByKey, + }) + : EMPTY_THREAD_JUMP_LABELS, + [ + getCurrentSidebarShortcutContext, + keybindings, + platform, + showThreadJumpHints, + threadJumpCommandByKey, + ], + ); + const threadJumpLabelsRef = useRef>(threadJumpLabelByKey); + threadJumpLabelsRef.current = threadJumpLabelByKey; + const showThreadJumpHintsRef = useRef(showThreadJumpHints); + showThreadJumpHintsRef.current = showThreadJumpHints; + + useEffect(() => { + const clearThreadJumpHints = () => { + updateThreadJumpHintsVisibility(false); + }; + const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + event.key !== "Meta" && + event.key !== "Control" && + event.key !== "Alt" && + event.key !== "Shift" && + !showThreadJumpHintsRef.current && + threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + if ( + showThreadJumpHintsRef.current || + threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS + ) { + clearThreadJumpHints(); + } + } else { + updateThreadJumpHintsVisibility(true); + } + + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: shortcutContext, + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: visibleSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }); + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + }; + + const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + clearThreadJumpHints(); + return; + } + updateThreadJumpHintsVisibility(true); + }; + + const onWindowBlur = () => { + clearThreadJumpHints(); + }; + + window.addEventListener("keydown", onWindowKeyDown); + window.addEventListener("keyup", onWindowKeyUp); + window.addEventListener("blur", onWindowBlur); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + window.removeEventListener("keyup", onWindowKeyUp); + window.removeEventListener("blur", onWindowBlur); + }; + }, [ + getCurrentSidebarShortcutContext, + keybindings, + navigateToThread, + platform, + routeThreadKey, + sidebarThreadSnapshotByKey, + threadJumpCommandByKey, + threadJumpThreadKeys, + updateThreadJumpHintsVisibility, + visibleSidebarThreadKeys, + ]); + + return threadJumpLabelByKey; +} + +export const SidebarProjectOrderingController = memo( + function SidebarProjectOrderingController(props: { + sidebarProjects: readonly SidebarProjectSnapshot[]; + physicalToLogicalKey: ReadonlyMap; + sidebarProjectSortOrder: SidebarProjectSortOrder; + }) { + const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; + const sidebarThreadSortSnapshots = useStore( + useMemo( + () => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(sidebarProjectSortOrder), + [sidebarProjectSortOrder], + ), + ); + const sortedProjectKeys = useMemo(() => { + if (sidebarProjectSortOrder === "manual") { + return sidebarProjects.map((project) => project.projectKey); + } + + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = sidebarThreadSortSnapshots + .filter((thread) => thread.archivedAt === null) + .map((thread) => { + const physicalKey = scopedProjectKey( + scopeProjectRef(thread.environmentId, thread.projectId), + ); + return { + id: thread.id, + environmentId: thread.environmentId, + projectId: physicalToLogicalKey.get(physicalKey) ?? physicalKey, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + latestUserMessageAt: thread.latestUserMessageAt, + }; + }); + return sortProjectsForSidebar(sortableProjects, sortableThreads, sidebarProjectSortOrder).map( + (project) => project.id, + ); + }, [ + physicalToLogicalKey, + sidebarProjectSortOrder, + sidebarProjects, + sidebarThreadSortSnapshots, + ]); + + useEffect(() => { + setSidebarProjectOrdering(sortedProjectKeys); + }, [sortedProjectKeys]); + + return null; + }, +); + +export const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { + navigateToThread: (threadRef: ScopedThreadRef) => void; + sidebarThreadSortOrder: SidebarThreadSortOrder; +}) { + const { navigateToThread, sidebarThreadSortOrder } = props; + const sortedProjectKeys = useSidebarProjectKeys(); + const physicalToLogicalKey = useSidebarPhysicalToLogicalKey(); + const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); + const sidebarThreadSortSnapshots = useStore( + useMemo( + () => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(sidebarThreadSortOrder), + [sidebarThreadSortOrder], + ), + ); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const activeRouteProjectKey = useMemo(() => { + if (!routeThreadRef) { + return null; + } + + const activeThread = sidebarThreadSortSnapshots.find( + (thread) => + thread.environmentId === routeThreadRef.environmentId && + thread.id === routeThreadRef.threadId, + ); + if (!activeThread) { + return null; + } + + const physicalKey = scopedProjectKey( + scopeProjectRef(activeThread.environmentId, activeThread.projectId), + ); + return physicalToLogicalKey.get(physicalKey) ?? physicalKey; + }, [physicalToLogicalKey, routeThreadRef, sidebarThreadSortSnapshots]); + const keybindings = useServerKeybindings(); + const platform = navigator.platform; + const threadJumpLabelByKey = useSidebarKeyboardController({ + sortedProjectKeys, + sidebarThreadSortSnapshots, + physicalToLogicalKey, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + sidebarThreadSortOrder, + }); + + useEffect(() => { + setSidebarKeyboardState({ + activeRouteProjectKey, + activeRouteThreadKey: routeThreadKey, + threadJumpLabelByKey, + }); + }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); + + return null; +}); diff --git a/apps/web/src/components/sidebar/sidebarSelectors.ts b/apps/web/src/components/sidebar/sidebarSelectors.ts index e095f6057b..f91c1864c6 100644 --- a/apps/web/src/components/sidebar/sidebarSelectors.ts +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -1,10 +1,20 @@ import type { EnvironmentId, ProjectId, ScopedProjectRef, ThreadId } from "@t3tools/contracts"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; -import { sortThreadsForSidebar } from "../Sidebar.logic"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; +import { + sortThreadsForSidebar, + type ThreadStatusLatestTurnSnapshot, + type ThreadStatusSessionSnapshot, +} from "../Sidebar.logic"; import type { AppState, EnvironmentState } from "../../store"; import type { SidebarThreadSummary } from "../../types"; +import type { LogicalProjectKey } from "../../logicalProject"; export interface SidebarThreadSortSnapshot { id: ThreadId; @@ -30,8 +40,8 @@ export interface ProjectThreadStatusInput { hasPendingApprovals: boolean; hasPendingUserInput: boolean; interactionMode: SidebarThreadSummary["interactionMode"]; - latestTurn: SidebarThreadSummary["latestTurn"]; - session: SidebarThreadSummary["session"]; + latestTurn: ThreadStatusLatestTurnSnapshot | null; + session: ThreadStatusSessionSnapshot | null; } export interface SidebarThreadRowSnapshot { @@ -47,7 +57,7 @@ interface ProjectThreadRenderEntry { threadKey: string; id: ThreadId; environmentId: EnvironmentId; - projectId: ProjectId; + projectId: LogicalProjectKey; createdAt: string; archivedAt: string | null; updatedAt?: string | undefined; @@ -73,6 +83,7 @@ const EMPTY_PROJECT_RENDER_STATE: SidebarProjectRenderStateSnapshot = { function collectProjectThreadEntries( state: AppState, memberProjectRefs: readonly ScopedProjectRef[], + physicalToLogicalKey?: ReadonlyMap, ): ProjectThreadRenderEntry[] { if (memberProjectRefs.length === 0) { return []; @@ -94,7 +105,10 @@ function collectProjectThreadEntries( threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), id: summary.id, environmentId: summary.environmentId, - projectId: summary.projectId, + projectId: + physicalToLogicalKey?.get( + scopedProjectKey(scopeProjectRef(summary.environmentId, summary.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(summary.environmentId, summary.projectId)), createdAt: summary.createdAt, archivedAt: summary.archivedAt, updatedAt: summary.updatedAt, @@ -131,8 +145,20 @@ function collectProjectThreadStatusInputs( hasPendingApprovals: summary.hasPendingApprovals, hasPendingUserInput: summary.hasPendingUserInput, interactionMode: summary.interactionMode, - latestTurn: summary.latestTurn, - session: summary.session, + latestTurn: summary.latestTurn + ? { + turnId: summary.latestTurn.turnId, + startedAt: summary.latestTurn.startedAt, + completedAt: summary.latestTurn.completedAt, + } + : null, + session: summary.session + ? { + orchestrationStatus: summary.session.orchestrationStatus, + activeTurnId: summary.session.activeTurnId, + status: summary.session.status, + } + : null, }); } } @@ -151,6 +177,7 @@ function projectThreadStatusInputsEqual( left.hasPendingApprovals === right.hasPendingApprovals && left.hasPendingUserInput === right.hasPendingUserInput && left.interactionMode === right.interactionMode && + left.latestTurn?.turnId === right.latestTurn?.turnId && left.latestTurn?.startedAt === right.latestTurn?.startedAt && left.latestTurn?.completedAt === right.latestTurn?.completedAt && left.session?.orchestrationStatus === right.session?.orchestrationStatus && @@ -159,16 +186,29 @@ function projectThreadStatusInputsEqual( ); } -export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(): ( - state: AppState, -) => SidebarThreadSortSnapshot[] { +function includeUpdatedSortFields( + sortOrder: SidebarProjectSortOrder | SidebarThreadSortOrder, +): boolean { + return sortOrder === "updated_at"; +} + +export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector( + sortOrder: SidebarProjectSortOrder | SidebarThreadSortOrder, +): (state: AppState) => SidebarThreadSortSnapshot[] { let previousResult = EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS; let previousEntries = new Map(); return (state) => { + if (sortOrder === "manual") { + previousEntries = new Map(); + previousResult = EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS; + return previousResult; + } + const nextEntries = new Map(); const nextResult: SidebarThreadSortSnapshot[] = []; let changed = false; + const watchUpdatedFields = includeUpdatedSortFields(sortOrder); for (const [environmentId, environmentState] of Object.entries( state.environmentStateById, @@ -188,8 +228,9 @@ export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(): ( previousEntry.projectId === summary.projectId && previousEntry.createdAt === summary.createdAt && previousEntry.archivedAt === summary.archivedAt && - previousEntry.updatedAt === summary.updatedAt && - previousEntry.latestUserMessageAt === summary.latestUserMessageAt + (!watchUpdatedFields || + (previousEntry.updatedAt === summary.updatedAt && + previousEntry.latestUserMessageAt === summary.latestUserMessageAt)) ) { nextEntries.set(entryKey, previousEntry); nextResult.push(previousEntry); @@ -310,6 +351,7 @@ export function createSidebarProjectRenderStateSelector(input: { activeRouteThreadKey: string | null; isThreadListExpanded: boolean; memberProjectRefs: readonly ScopedProjectRef[]; + physicalToLogicalKey?: ReadonlyMap; projectExpanded: boolean; previewLimit: number; threadSortOrder: SidebarThreadSortOrder; @@ -318,9 +360,11 @@ export function createSidebarProjectRenderStateSelector(input: { return (state) => { const visibleProjectThreads = sortThreadsForSidebar( - collectProjectThreadEntries(state, input.memberProjectRefs).filter( - (thread) => thread.archivedAt === null, - ), + collectProjectThreadEntries( + state, + input.memberProjectRefs, + input.physicalToLogicalKey, + ).filter((thread) => thread.archivedAt === null), input.threadSortOrder, ); const pinnedCollapsedThread = diff --git a/apps/web/src/components/sidebar/sidebarViewStore.ts b/apps/web/src/components/sidebar/sidebarViewStore.ts new file mode 100644 index 0000000000..5821d57e9f --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarViewStore.ts @@ -0,0 +1,222 @@ +import { useCallback } from "react"; +import type { ScopedProjectRef } from "@t3tools/contracts"; +import { useStore as useZustandStore } from "zustand"; +import { createStore } from "zustand/vanilla"; +import type { LogicalProjectKey } from "../../logicalProject"; +import type { Project } from "../../types"; + +export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; + +export type SidebarProjectSnapshot = Project & { + projectKey: LogicalProjectKey; + environmentPresence: EnvironmentPresence; + memberProjectRefs: readonly ScopedProjectRef[]; + remoteEnvironmentLabels: readonly string[]; +}; + +interface SidebarTransientState { + sortedProjectKeys: readonly LogicalProjectKey[]; + projectSnapshotByKey: ReadonlyMap; + physicalToLogicalKey: ReadonlyMap; + activeRouteThreadKey: string | null; + activeRouteProjectKey: LogicalProjectKey | null; + threadJumpLabelByKey: ReadonlyMap; + expandedThreadListsByProject: ReadonlySet; +} + +const EMPTY_THREAD_JUMP_LABELS = new Map(); +const EMPTY_SIDEBAR_PROJECT_KEYS: LogicalProjectKey[] = []; +const EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY = new Map(); +const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); + +const sidebarViewStore = createStore(() => ({ + sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, + projectSnapshotByKey: EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY, + physicalToLogicalKey: new Map(), + activeRouteThreadKey: null, + activeRouteProjectKey: null, + threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, + expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, +})); + +function refsEqual(left: readonly ScopedProjectRef[], right: readonly ScopedProjectRef[]): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.projectId === right[index]?.projectId, + ) + ); +} + +export function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +export function sidebarProjectSnapshotsEqual( + left: SidebarProjectSnapshot | undefined, + right: SidebarProjectSnapshot, +): boolean { + return ( + left !== undefined && + left.id === right.id && + left.environmentId === right.environmentId && + left.name === right.name && + left.cwd === right.cwd && + left.repositoryIdentity === right.repositoryIdentity && + left.defaultModelSelection === right.defaultModelSelection && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + left.scripts === right.scripts && + left.projectKey === right.projectKey && + left.environmentPresence === right.environmentPresence && + refsEqual(left.memberProjectRefs, right.memberProjectRefs) && + stringArraysEqual(left.remoteEnvironmentLabels, right.remoteEnvironmentLabels) + ); +} + +export function useSidebarProjectKeys(): readonly LogicalProjectKey[] { + return useZustandStore(sidebarViewStore, (state) => state.sortedProjectKeys); +} + +export function useSidebarPhysicalToLogicalKey(): ReadonlyMap { + return useZustandStore(sidebarViewStore, (state) => state.physicalToLogicalKey); +} + +export function useSidebarProjectSnapshot( + projectKey: LogicalProjectKey, +): SidebarProjectSnapshot | null { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => state.projectSnapshotByKey.get(projectKey) ?? null, + [projectKey], + ), + ); +} + +export function useSidebarProjectThreadListExpanded(projectKey: LogicalProjectKey): boolean { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => state.expandedThreadListsByProject.has(projectKey), + [projectKey], + ), + ); +} + +export function useSidebarExpandedThreadListsByProject(): ReadonlySet { + return useZustandStore(sidebarViewStore, (state) => state.expandedThreadListsByProject); +} + +export function expandSidebarProjectThreadList(projectKey: LogicalProjectKey): void { + const { expandedThreadListsByProject } = sidebarViewStore.getState(); + if (expandedThreadListsByProject.has(projectKey)) { + return; + } + + sidebarViewStore.setState({ + expandedThreadListsByProject: new Set([...expandedThreadListsByProject, projectKey]), + }); +} + +export function collapseSidebarProjectThreadList(projectKey: LogicalProjectKey): void { + const { expandedThreadListsByProject } = sidebarViewStore.getState(); + if (!expandedThreadListsByProject.has(projectKey)) { + return; + } + + const nextExpandedThreadListsByProject = new Set(expandedThreadListsByProject); + nextExpandedThreadListsByProject.delete(projectKey); + sidebarViewStore.setState({ + expandedThreadListsByProject: + nextExpandedThreadListsByProject.size === 0 + ? EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT + : nextExpandedThreadListsByProject, + }); +} + +export function useSidebarIsActiveThread(threadKey: string): boolean { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => state.activeRouteThreadKey === threadKey, + [threadKey], + ), + ); +} + +export function useSidebarThreadJumpLabel(threadKey: string): string | null { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => state.threadJumpLabelByKey.get(threadKey) ?? null, + [threadKey], + ), + ); +} + +export function useSidebarProjectActiveRouteThreadKey( + projectKey: LogicalProjectKey, +): string | null { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => { + return state.activeRouteProjectKey === projectKey ? state.activeRouteThreadKey : null; + }, + [projectKey], + ), + ); +} + +export function syncSidebarProjectMappings(input: { + projectSnapshotByKey: ReadonlyMap; + physicalToLogicalKey: ReadonlyMap; +}): void { + const currentState = sidebarViewStore.getState(); + if ( + currentState.projectSnapshotByKey === input.projectSnapshotByKey && + currentState.physicalToLogicalKey === input.physicalToLogicalKey + ) { + return; + } + + sidebarViewStore.setState({ + projectSnapshotByKey: input.projectSnapshotByKey, + physicalToLogicalKey: input.physicalToLogicalKey, + }); +} + +export function setSidebarProjectOrdering(sortedProjectKeys: readonly LogicalProjectKey[]): void { + const currentState = sidebarViewStore.getState(); + if (stringArraysEqual(currentState.sortedProjectKeys, sortedProjectKeys)) { + return; + } + + sidebarViewStore.setState({ + sortedProjectKeys, + }); +} + +export function setSidebarKeyboardState(input: { + activeRouteProjectKey: LogicalProjectKey | null; + activeRouteThreadKey: string | null; + threadJumpLabelByKey: ReadonlyMap; +}): void { + const currentState = sidebarViewStore.getState(); + if ( + currentState.activeRouteThreadKey === input.activeRouteThreadKey && + currentState.activeRouteProjectKey === input.activeRouteProjectKey && + currentState.threadJumpLabelByKey === input.threadJumpLabelByKey + ) { + return; + } + + sidebarViewStore.setState({ + activeRouteThreadKey: input.activeRouteThreadKey, + activeRouteProjectKey: input.activeRouteProjectKey, + threadJumpLabelByKey: input.threadJumpLabelByKey, + }); +} diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 789441877b..3d4889b838 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -2,9 +2,11 @@ import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; import type { ScopedProjectRef } from "@t3tools/contracts"; import type { Project } from "./types"; +export type LogicalProjectKey = string; + export function deriveLogicalProjectKey( project: Pick, -): string { +): LogicalProjectKey { return ( project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) @@ -14,6 +16,6 @@ export function deriveLogicalProjectKey( export function deriveLogicalProjectKeyFromRef( projectRef: ScopedProjectRef, project: Pick | null | undefined, -): string { +): LogicalProjectKey { return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); } From 5eff638a44841a45735bc2255d37fa8d23f6f7e3 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:10:04 +0100 Subject: [PATCH 3/9] Finalize sidebar cleanup and selector narrowing --- apps/web/src/components/Sidebar.logic.ts | 2 +- apps/web/src/components/Sidebar.tsx | 428 +++++++----------- .../components/sidebar/sidebarControllers.tsx | 46 +- .../sidebar/sidebarProjectSnapshots.ts | 133 ++++++ .../components/sidebar/sidebarSelectors.ts | 85 +++- 5 files changed, 405 insertions(+), 289 deletions(-) create mode 100644 apps/web/src/components/sidebar/sidebarProjectSnapshots.ts diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4dce21cba6..383abce854 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -5,7 +5,7 @@ import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; -export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 0; +export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 75; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4bedf5ce5e..440a498465 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -122,6 +122,7 @@ import { import { createSidebarProjectRenderStateSelector, createSidebarProjectThreadStatusInputsSelector, + createSidebarThreadMetaSnapshotSelectorByRef, createSidebarThreadRowSnapshotSelectorByRef, createSidebarThreadStatusInputSelectorByRef, type ProjectThreadStatusInput, @@ -134,7 +135,6 @@ import { import { collapseSidebarProjectThreadList, expandSidebarProjectThreadList, - sidebarProjectSnapshotsEqual, syncSidebarProjectMappings, useSidebarIsActiveThread, useSidebarProjectActiveRouteThreadKey, @@ -145,16 +145,18 @@ import { type SidebarProjectSnapshot, } from "./sidebar/sidebarViewStore"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { + buildSidebarPhysicalToLogicalKeyMap, + buildSidebarProjectSnapshots, +} from "./sidebar/sidebarProjectSnapshots"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { deriveLogicalProjectKey } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { Project } from "../types"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -192,11 +194,6 @@ function useSidebarThreadStatusInput( ); } -function useSidebarThreadRunningState(threadRef: ScopedThreadRef | null): boolean { - const statusInput = useSidebarThreadStatusInput(threadRef); - return statusInput?.session?.status === "running" && statusInput.session.activeTurnId != null; -} - function ThreadStatusLabel({ status, compact = false, @@ -289,81 +286,174 @@ function resolveThreadPr( return gitStatus.pr ?? null; } -const SidebarThreadMetaInfo = memo(function SidebarThreadMetaInfo(props: { - threadKey: string; - threadRef: ScopedThreadRef; +const SidebarThreadMetaCluster = memo(function SidebarThreadMetaCluster(props: { + appSettingsConfirmThreadArchive: boolean; + confirmArchiveButtonRef: React.RefObject; + handleArchiveImmediateClick: (event: React.MouseEvent) => void; + handleConfirmArchiveClick: (event: React.MouseEvent) => void; + handleStartArchiveConfirmation: (event: React.MouseEvent) => void; + isConfirmingArchive: boolean; isHighlighted: boolean; isRemoteThread: boolean; + stopPropagationOnPointerDown: (event: React.PointerEvent) => void; threadEnvironmentLabel: string | null; - isConfirmingArchive: boolean; + threadId: ThreadId; + threadKey: string; + threadRef: ScopedThreadRef; + threadTitle: string; }) { const { - threadKey, - threadRef, + appSettingsConfirmThreadArchive, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchive, isHighlighted, isRemoteThread, + stopPropagationOnPointerDown, threadEnvironmentLabel, - isConfirmingArchive, + threadId, + threadKey, + threadRef, + threadTitle, } = props; const jumpLabel = useSidebarThreadJumpLabel(threadKey); - const isThreadRunning = useSidebarThreadRunningState(threadRef); + const metaSnapshot = useStore( + useMemo(() => createSidebarThreadMetaSnapshotSelectorByRef(threadRef), [threadRef]), + ); + const isThreadRunning = metaSnapshot?.isRunning ?? false; const hidden = isConfirmingArchive && !isThreadRunning; - const relativeTimestamp = useStore( - useMemo( - () => (state: import("../store").AppState) => { - const thread = selectSidebarThreadSummaryByRef(state, threadRef); - return thread ? formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt) : null; - }, - [threadRef], - ), + const relativeTimestamp = useMemo( + () => (metaSnapshot ? formatRelativeTimeLabel(metaSnapshot.activityTimestamp) : null), + [metaSnapshot], ); + const isConfirmingArchiveVisible = isConfirmingArchive && !isThreadRunning; + + const archiveControl = useMemo(() => { + if (isConfirmingArchiveVisible) { + return ( + + ); + } + + if (isThreadRunning) { + return null; + } + + if (appSettingsConfirmThreadArchive) { + return ( +
+ +
+ ); + } + + return ( + + + + + } + /> + Archive + + ); + }, [ + appSettingsConfirmThreadArchive, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchiveVisible, + isThreadRunning, + stopPropagationOnPointerDown, + threadId, + threadTitle, + ]); return ( -
- + ); }); @@ -412,96 +502,6 @@ const SidebarThreadTerminalStatusIndicator = memo( }, ); -const SidebarThreadArchiveControl = memo(function SidebarThreadArchiveControl(props: { - appSettingsConfirmThreadArchive: boolean; - confirmArchiveButtonRef: React.RefObject; - handleArchiveImmediateClick: (event: React.MouseEvent) => void; - handleConfirmArchiveClick: (event: React.MouseEvent) => void; - handleStartArchiveConfirmation: (event: React.MouseEvent) => void; - isConfirmingArchive: boolean; - stopPropagationOnPointerDown: (event: React.PointerEvent) => void; - threadId: ThreadId; - threadRef: ScopedThreadRef; - threadTitle: string; -}) { - const { - appSettingsConfirmThreadArchive, - confirmArchiveButtonRef, - handleArchiveImmediateClick, - handleConfirmArchiveClick, - handleStartArchiveConfirmation, - isConfirmingArchive, - stopPropagationOnPointerDown, - threadId, - threadRef, - threadTitle, - } = props; - const isThreadRunning = useSidebarThreadRunningState(threadRef); - const isConfirmingArchiveVisible = isConfirmingArchive && !isThreadRunning; - - if (isConfirmingArchiveVisible) { - return ( - - ); - } - - if (isThreadRunning) { - return null; - } - - if (appSettingsConfirmThreadArchive) { - return ( -
- -
- ); - } - - return ( - - - - - } - /> - Archive - - ); -}); - interface SidebarThreadRowProps { threadKey: string; projectKey: string; @@ -1068,25 +1068,21 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
- -
@@ -2295,97 +2291,25 @@ export default function Sidebar() { }); }, [projectOrder, projects]); - // Build a mapping from physical project key → logical project key for - // cross-environment grouping. Projects that share a repositoryIdentity - // canonicalKey are treated as one logical project in the sidebar. - const physicalToLogicalKey = useMemo(() => { - const mapping = new Map(); - for (const project of orderedProjects) { - const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); - mapping.set(physicalKey, deriveLogicalProjectKey(project)); - } - return mapping; - }, [orderedProjects]); + const physicalToLogicalKey = useMemo( + () => buildSidebarPhysicalToLogicalKeyMap(orderedProjects), + [orderedProjects], + ); const previousSidebarProjectSnapshotByKeyRef = useRef< ReadonlyMap >(new Map()); const sidebarProjects = useMemo(() => { - // Group projects by logical key while preserving insertion order from - // orderedProjects. - const groupedMembers = new Map(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - const existing = groupedMembers.get(logicalKey); - if (existing) { - existing.push(project); - } else { - groupedMembers.set(logicalKey, [project]); - } - } - - const previousSidebarProjectSnapshotByKey = previousSidebarProjectSnapshotByKeyRef.current; - const nextSidebarProjectSnapshotByKey = new Map(); - const result: SidebarProjectSnapshot[] = []; - const seen = new Set(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - if (seen.has(logicalKey)) continue; - seen.add(logicalKey); - - const members = groupedMembers.get(logicalKey)!; - // Prefer the primary environment's project as the representative. - const representative: Project | undefined = - (primaryEnvironmentId - ? members.find((p) => p.environmentId === primaryEnvironmentId) - : undefined) ?? members[0]; - if (!representative) continue; - const hasLocal = - primaryEnvironmentId !== null && - members.some((p) => p.environmentId === primaryEnvironmentId); - const hasRemote = - primaryEnvironmentId !== null - ? members.some((p) => p.environmentId !== primaryEnvironmentId) - : false; - - const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); - const remoteLabels = members - .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) - .map((p) => { - const rt = savedEnvironmentRuntimeById[p.environmentId]; - const saved = savedEnvironmentRegistry[p.environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; - }); - const nextSnapshot: SidebarProjectSnapshot = { - id: representative.id, - environmentId: representative.environmentId, - name: representative.name, - cwd: representative.cwd, - repositoryIdentity: representative.repositoryIdentity ?? null, - defaultModelSelection: representative.defaultModelSelection, - createdAt: representative.createdAt, - updatedAt: representative.updatedAt, - scripts: representative.scripts, - projectKey: logicalKey, - environmentPresence: - hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", - memberProjectRefs: refs, - remoteEnvironmentLabels: remoteLabels, - }; - const snapshot = sidebarProjectSnapshotsEqual( - previousSidebarProjectSnapshotByKey.get(logicalKey), - nextSnapshot, - ) - ? (previousSidebarProjectSnapshotByKey.get(logicalKey) as SidebarProjectSnapshot) - : nextSnapshot; - nextSidebarProjectSnapshotByKey.set(logicalKey, snapshot); - result.push(snapshot); - } - previousSidebarProjectSnapshotByKeyRef.current = - nextSidebarProjectSnapshotByKey.size === 0 - ? new Map() - : nextSidebarProjectSnapshotByKey; - return result; + const { projectSnapshotByKey, sidebarProjects: nextSidebarProjects } = + buildSidebarProjectSnapshots({ + orderedProjects, + previousProjectSnapshotByKey: previousSidebarProjectSnapshotByKeyRef.current, + primaryEnvironmentId, + savedEnvironmentRegistryById: savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + }); + previousSidebarProjectSnapshotByKeyRef.current = projectSnapshotByKey; + return [...nextSidebarProjects]; }, [ orderedProjects, primaryEnvironmentId, diff --git a/apps/web/src/components/sidebar/sidebarControllers.tsx b/apps/web/src/components/sidebar/sidebarControllers.tsx index c098aded95..5db4454004 100644 --- a/apps/web/src/components/sidebar/sidebarControllers.tsx +++ b/apps/web/src/components/sidebar/sidebarControllers.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { + parseScopedThreadKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, @@ -27,6 +28,7 @@ import { useThreadJumpHintVisibility, } from "../Sidebar.logic"; import { + createSidebarActiveRouteProjectKeySelectorByRef, createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector, type SidebarThreadSortSnapshot, } from "./sidebarSelectors"; @@ -101,16 +103,6 @@ function useSidebarKeyboardController(input: { } = input; const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const sidebarThreadSnapshotByKey = useMemo( - () => - new Map( - sidebarThreadSortSnapshots.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), - [sidebarThreadSortSnapshots], - ); const threadsByProjectKey = useMemo(() => { const next = new Map(); for (const thread of sidebarThreadSortSnapshots) { @@ -274,14 +266,14 @@ function useSidebarKeyboardController(input: { if (!targetThreadKey) { return; } - const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + const targetThread = parseScopedThreadKey(targetThreadKey); if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + navigateToThread(targetThread); return; } @@ -294,14 +286,14 @@ function useSidebarKeyboardController(input: { if (!targetThreadKey) { return; } - const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + const targetThread = parseScopedThreadKey(targetThreadKey); if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + navigateToThread(targetThread); }; const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { @@ -340,7 +332,6 @@ function useSidebarKeyboardController(input: { navigateToThread, platform, routeThreadKey, - sidebarThreadSnapshotByKey, threadJumpCommandByKey, threadJumpThreadKeys, updateThreadJumpHintsVisibility, @@ -425,25 +416,12 @@ export const SidebarKeyboardController = memo(function SidebarKeyboardController select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const activeRouteProjectKey = useMemo(() => { - if (!routeThreadRef) { - return null; - } - - const activeThread = sidebarThreadSortSnapshots.find( - (thread) => - thread.environmentId === routeThreadRef.environmentId && - thread.id === routeThreadRef.threadId, - ); - if (!activeThread) { - return null; - } - - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); - return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [physicalToLogicalKey, routeThreadRef, sidebarThreadSortSnapshots]); + const activeRouteProjectKey = useStore( + useMemo( + () => createSidebarActiveRouteProjectKeySelectorByRef(routeThreadRef, physicalToLogicalKey), + [physicalToLogicalKey, routeThreadRef], + ), + ); const keybindings = useServerKeybindings(); const platform = navigator.platform; const threadJumpLabelByKey = useSidebarKeyboardController({ diff --git a/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts new file mode 100644 index 0000000000..504c1f0097 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts @@ -0,0 +1,133 @@ +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { deriveLogicalProjectKey, type LogicalProjectKey } from "../../logicalProject"; +import type { Project } from "../../types"; +import { sidebarProjectSnapshotsEqual, type SidebarProjectSnapshot } from "./sidebarViewStore"; + +type SavedEnvironmentRegistryEntry = { + label?: string | null; +} | null; + +type SavedEnvironmentRuntimeEntry = { + descriptor?: { + label?: string | null; + } | null; +} | null; + +export function buildSidebarPhysicalToLogicalKeyMap( + orderedProjects: readonly Project[], +): ReadonlyMap { + const mapping = new Map(); + for (const project of orderedProjects) { + const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); + mapping.set(physicalKey, deriveLogicalProjectKey(project)); + } + return mapping; +} + +export function buildSidebarProjectSnapshots(input: { + orderedProjects: readonly Project[]; + previousProjectSnapshotByKey: ReadonlyMap; + primaryEnvironmentId: EnvironmentId | null; + savedEnvironmentRegistryById: Readonly>; + savedEnvironmentRuntimeById: Readonly>; +}): { + projectSnapshotByKey: ReadonlyMap; + sidebarProjects: readonly SidebarProjectSnapshot[]; +} { + const { + orderedProjects, + previousProjectSnapshotByKey, + primaryEnvironmentId, + savedEnvironmentRegistryById, + savedEnvironmentRuntimeById, + } = input; + + const groupedMembers = new Map(); + for (const project of orderedProjects) { + const logicalKey = deriveLogicalProjectKey(project); + const existingMembers = groupedMembers.get(logicalKey); + if (existingMembers) { + existingMembers.push(project); + continue; + } + groupedMembers.set(logicalKey, [project]); + } + + const nextProjectSnapshotByKey = new Map(); + const sidebarProjects: SidebarProjectSnapshot[] = []; + const emittedProjectKeys = new Set(); + + for (const project of orderedProjects) { + const logicalKey = deriveLogicalProjectKey(project); + if (emittedProjectKeys.has(logicalKey)) { + continue; + } + emittedProjectKeys.add(logicalKey); + + const members = groupedMembers.get(logicalKey); + if (!members || members.length === 0) { + continue; + } + + const representative = + (primaryEnvironmentId + ? members.find((member) => member.environmentId === primaryEnvironmentId) + : undefined) ?? members[0]; + if (!representative) { + continue; + } + + const hasLocal = + primaryEnvironmentId !== null && + members.some((member) => member.environmentId === primaryEnvironmentId); + const hasRemote = + primaryEnvironmentId !== null + ? members.some((member) => member.environmentId !== primaryEnvironmentId) + : false; + + const nextSnapshot: SidebarProjectSnapshot = { + id: representative.id, + environmentId: representative.environmentId, + name: representative.name, + cwd: representative.cwd, + repositoryIdentity: representative.repositoryIdentity ?? null, + defaultModelSelection: representative.defaultModelSelection, + createdAt: representative.createdAt, + updatedAt: representative.updatedAt, + scripts: representative.scripts, + projectKey: logicalKey, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), + remoteEnvironmentLabels: members + .filter( + (member) => + primaryEnvironmentId !== null && member.environmentId !== primaryEnvironmentId, + ) + .map((member) => { + const runtimeEnvironment = savedEnvironmentRuntimeById[member.environmentId]; + const savedEnvironment = savedEnvironmentRegistryById[member.environmentId]; + return ( + runtimeEnvironment?.descriptor?.label ?? savedEnvironment?.label ?? member.environmentId + ); + }), + }; + + const cachedSnapshot = previousProjectSnapshotByKey.get(logicalKey); + const snapshot = + cachedSnapshot && sidebarProjectSnapshotsEqual(cachedSnapshot, nextSnapshot) + ? cachedSnapshot + : nextSnapshot; + nextProjectSnapshotByKey.set(logicalKey, snapshot); + sidebarProjects.push(snapshot); + } + + return { + projectSnapshotByKey: + nextProjectSnapshotByKey.size === 0 + ? new Map() + : nextProjectSnapshotByKey, + sidebarProjects, + }; +} diff --git a/apps/web/src/components/sidebar/sidebarSelectors.ts b/apps/web/src/components/sidebar/sidebarSelectors.ts index f91c1864c6..ac6c903715 100644 --- a/apps/web/src/components/sidebar/sidebarSelectors.ts +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -53,6 +53,11 @@ export interface SidebarThreadRowSnapshot { worktreePath: string | null; } +export interface SidebarThreadMetaSnapshot { + activityTimestamp: string; + isRunning: boolean; +} + interface ProjectThreadRenderEntry { threadKey: string; id: ThreadId; @@ -334,8 +339,20 @@ export function createSidebarThreadStatusInputSelectorByRef( hasPendingApprovals: summary.hasPendingApprovals, hasPendingUserInput: summary.hasPendingUserInput, interactionMode: summary.interactionMode, - latestTurn: summary.latestTurn, - session: summary.session, + latestTurn: summary.latestTurn + ? { + turnId: summary.latestTurn.turnId, + startedAt: summary.latestTurn.startedAt, + completedAt: summary.latestTurn.completedAt, + } + : null, + session: summary.session + ? { + orchestrationStatus: summary.session.orchestrationStatus, + activeTurnId: summary.session.activeTurnId, + status: summary.session.status, + } + : null, }; if (projectThreadStatusInputsEqual(previousResult, nextResult)) { @@ -347,6 +364,70 @@ export function createSidebarThreadStatusInputSelectorByRef( }; } +export function createSidebarThreadMetaSnapshotSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => SidebarThreadMetaSnapshot | undefined { + let previousResult: SidebarThreadMetaSnapshot | undefined; + + return (state) => { + if (!ref) { + return undefined; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + return undefined; + } + + const nextResult: SidebarThreadMetaSnapshot = { + activityTimestamp: summary.updatedAt ?? summary.createdAt, + isRunning: summary.session?.status === "running" && summary.session.activeTurnId != null, + }; + + if ( + previousResult && + previousResult.activityTimestamp === nextResult.activityTimestamp && + previousResult.isRunning === nextResult.isRunning + ) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarActiveRouteProjectKeySelectorByRef( + ref: ScopedThreadRef | null | undefined, + physicalToLogicalKey: ReadonlyMap, +): (state: AppState) => LogicalProjectKey | null { + let previousResult: LogicalProjectKey | null = null; + + return (state) => { + if (!ref) { + previousResult = null; + return null; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + previousResult = null; + return null; + } + + const physicalKey = scopedProjectKey(scopeProjectRef(summary.environmentId, summary.projectId)); + const nextResult = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + if (previousResult === nextResult) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + export function createSidebarProjectRenderStateSelector(input: { activeRouteThreadKey: string | null; isThreadListExpanded: boolean; From 6aea4a213e19575609008d3f1f52fb23e3902640 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:30:30 +0100 Subject: [PATCH 4/9] Harden sidebar controllers and view state --- apps/web/src/components/Sidebar.tsx | 159 ++++++++-------- .../components/sidebar/sidebarControllers.tsx | 177 ++++++++---------- .../sidebar/sidebarProjectSnapshots.ts | 85 ++++++++- .../components/sidebar/sidebarSelectors.ts | 116 ++++++++++-- .../components/sidebar/sidebarViewStore.ts | 89 +-------- 5 files changed, 349 insertions(+), 277 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 440a498465..40f8fc03e0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -135,19 +135,18 @@ import { import { collapseSidebarProjectThreadList, expandSidebarProjectThreadList, - syncSidebarProjectMappings, + resetSidebarViewState, useSidebarIsActiveThread, useSidebarProjectActiveRouteThreadKey, useSidebarProjectKeys, - useSidebarProjectSnapshot, useSidebarProjectThreadListExpanded, useSidebarThreadJumpLabel, - type SidebarProjectSnapshot, } from "./sidebar/sidebarViewStore"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { buildSidebarPhysicalToLogicalKeyMap, buildSidebarProjectSnapshots, + type SidebarProjectSnapshot, } from "./sidebar/sidebarProjectSnapshots"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { readEnvironmentApi } from "../environmentApi"; @@ -504,13 +503,12 @@ const SidebarThreadTerminalStatusIndicator = memo( interface SidebarThreadRowProps { threadKey: string; - projectKey: string; + project: SidebarProjectSnapshot; } const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { - const { threadKey, projectKey } = props; + const { threadKey, project } = props; const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); - const project = useSidebarProjectSnapshot(projectKey); const threadSortOrder = useSettings( (settings) => settings.sidebarThreadSortOrder, ); @@ -1092,7 +1090,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }); interface SidebarProjectThreadListProps { - projectKey: string; + project: SidebarProjectSnapshot; projectExpanded: boolean; hasOverflowingThreads: boolean; hiddenThreadKeys: readonly string[]; @@ -1107,7 +1105,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( props: SidebarProjectThreadListProps, ) { const { - projectKey, + project, projectExpanded, hasOverflowingThreads, hiddenThreadKeys, @@ -1137,7 +1135,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( ) : null} {shouldShowThreadPanel && renderedThreadKeys.map((threadKey) => { - return ; + return ; })} {projectExpanded && hasOverflowingThreads && !isThreadListExpanded && ( @@ -1148,12 +1146,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( 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={() => { - expandSidebarProjectThreadList(projectKey); + expandSidebarProjectThreadList(project.projectKey); }} > Show more @@ -1169,7 +1167,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( 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={() => { - collapseSidebarProjectThreadList(projectKey); + collapseSidebarProjectThreadList(project.projectKey); }} > Show less @@ -1322,11 +1320,10 @@ const SidebarProjectHeaderStatusIndicator = memo( const SidebarProjectOverflowStatusLabel = memo(function SidebarProjectOverflowStatusLabel(props: { hiddenThreadKeys: readonly string[]; - projectKey: string; + project: SidebarProjectSnapshot; }) { - const { hiddenThreadKeys, projectKey } = props; - const project = useSidebarProjectSnapshot(projectKey); - if (!project || hiddenThreadKeys.length === 0) { + const { hiddenThreadKeys, project } = props; + if (hiddenThreadKeys.length === 0) { return null; } const statusInputs = useSidebarProjectStatusInputs(project); @@ -1345,23 +1342,19 @@ const SidebarProjectOverflowStatusLabel = memo(function SidebarProjectOverflowSt }); interface SidebarProjectThreadSectionProps { - projectKey: string; + project: SidebarProjectSnapshot; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; } const SidebarProjectThreadSection = memo(function SidebarProjectThreadSection( props: SidebarProjectThreadSectionProps, ) { - const { projectKey, attachThreadListAutoAnimateRef } = props; - const project = useSidebarProjectSnapshot(projectKey); - const isThreadListExpanded = useSidebarProjectThreadListExpanded(projectKey); + const { project, attachThreadListAutoAnimateRef } = props; + const isThreadListExpanded = useSidebarProjectThreadListExpanded(project.projectKey); const threadSortOrder = useSettings( (settings) => settings.sidebarThreadSortOrder, ); - if (!project) { - return null; - } - const activeRouteThreadKey = useSidebarProjectActiveRouteThreadKey(projectKey); + const activeRouteThreadKey = useSidebarProjectActiveRouteThreadKey(project.projectKey); const projectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); @@ -1381,7 +1374,7 @@ const SidebarProjectThreadSection = memo(function SidebarProjectThreadSection( return ( ; suppressProjectClickAfterDragRef: React.RefObject; suppressProjectClickForContextMenuRef: React.RefObject; @@ -1405,14 +1398,13 @@ interface SidebarProjectHeaderProps { const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarProjectHeaderProps) { const { - projectKey, + project, dragInProgressRef, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, isManualProjectSorting, dragHandleProps, } = props; - const project = useSidebarProjectSnapshot(projectKey); const defaultThreadEnvMode = useSettings( (settings) => settings.defaultThreadEnvMode, ); @@ -1448,7 +1440,9 @@ const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarPr }); }, }); - const projectExpanded = useUiStateStore((state) => state.projectExpandedById[projectKey] ?? true); + const projectExpanded = useUiStateStore( + (state) => state.projectExpandedById[project.projectKey] ?? true, + ); const projectThreadCount = useSidebarProjectThreadCount(project); const newThreadShortcutLabelOptions = useMemo( () => ({ @@ -1463,10 +1457,6 @@ const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarPr const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); - if (!project) { - return null; - } - const handleProjectButtonClick = useCallback( (event: React.MouseEvent) => { if (suppressProjectClickForContextMenuRef.current) { @@ -1489,12 +1479,12 @@ const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarPr if (selectedThreadCount > 0) { clearSelection(); } - toggleProject(projectKey); + toggleProject(project.projectKey); }, [ clearSelection, dragInProgressRef, - projectKey, + project.projectKey, selectedThreadCount, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, @@ -1509,9 +1499,9 @@ const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarPr if (dragInProgressRef.current) { return; } - toggleProject(projectKey); + toggleProject(project.projectKey); }, - [dragInProgressRef, projectKey, toggleProject], + [dragInProgressRef, project.projectKey, toggleProject], ); const handleProjectButtonPointerDownCapture = useCallback( @@ -1733,7 +1723,7 @@ const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarPr }); interface SidebarProjectItemProps { - projectKey: string; + project: SidebarProjectSnapshot; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; dragInProgressRef: React.RefObject; suppressProjectClickAfterDragRef: React.RefObject; @@ -1744,7 +1734,7 @@ interface SidebarProjectItemProps { const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { const { - projectKey, + project, attachThreadListAutoAnimateRef, dragInProgressRef, suppressProjectClickAfterDragRef, @@ -1756,7 +1746,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return ( <> @@ -1973,6 +1963,7 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { }); interface SidebarProjectsContentProps { + sidebarProjectByKey: ReadonlyMap; showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; desktopUpdateButtonAction: "download" | "install" | "none"; @@ -2013,6 +2004,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( props: SidebarProjectsContentProps, ) { const { + sidebarProjectByKey, showArm64IntelBuildWarning, arm64IntelBuildWarningDescription, desktopUpdateButtonAction, @@ -2202,40 +2194,56 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( items={[...sortedProjectKeys]} strategy={verticalListSortingStrategy} > - {sortedProjectKeys.map((projectKey) => ( - - {(dragHandleProps) => ( - - )} - - ))} + {sortedProjectKeys.map((projectKey) => + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; + } + return ( + + {(dragHandleProps) => ( + + )} + + ); + })(), + )} ) : ( - {sortedProjectKeys.map((projectKey) => ( - - ))} + {sortedProjectKeys.map((projectKey) => + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; + } + return ( + + ); + })(), + )} )} @@ -2534,11 +2542,10 @@ export default function Sidebar() { const isManualProjectSorting = sidebarProjectSortOrder === "manual"; useEffect(() => { - syncSidebarProjectMappings({ - projectSnapshotByKey: sidebarProjectByKey, - physicalToLogicalKey, - }); - }, [physicalToLogicalKey, sidebarProjectByKey]); + return () => { + resetSidebarViewState(); + }; + }, []); useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { @@ -2669,6 +2676,7 @@ export default function Sidebar() { /> @@ -2677,6 +2685,7 @@ export default function Sidebar() { ) : ( <> ; + sortedThreadKeysByLogicalProject: ReadonlyMap; expandedThreadListsByProject: ReadonlySet; routeThreadRef: ScopedThreadRef | null; routeThreadKey: string | null; platform: string; keybindings: ReturnType; navigateToThread: (threadRef: ScopedThreadRef) => void; - sidebarThreadSortOrder: SidebarThreadSortOrder; }) { const { sortedProjectKeys, - sidebarThreadSortSnapshots, - physicalToLogicalKey, + sortedThreadKeysByLogicalProject, expandedThreadListsByProject, routeThreadRef, routeThreadKey, platform, keybindings, navigateToThread, - sidebarThreadSortOrder, } = input; const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const threadsByProjectKey = useMemo(() => { - const next = new Map(); - for (const thread of sidebarThreadSortSnapshots) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const existing = next.get(logicalKey); - if (existing) { - existing.push(thread); - } else { - next.set(logicalKey, [thread]); - } - } - return next; - }, [physicalToLogicalKey, sidebarThreadSortSnapshots]); const visibleSidebarThreadKeys = useMemo( () => sortedProjectKeys.flatMap((projectKey) => { - const projectThreads = sortThreadsForSidebar( - (threadsByProjectKey.get(projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, - ), - sidebarThreadSortOrder, - ); + const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; const projectExpanded = projectExpandedById[projectKey] ?? true; const activeThreadKey = routeThreadKey ?? undefined; const pinnedCollapsedThread = !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) + ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) : null; const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; if (!shouldShowThreadPanel) { return []; } const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); - const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; const previewThreads = isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); + ? projectThreadKeys + : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); + return pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; }), [ expandedThreadListsByProject, projectExpandedById, routeThreadKey, - sidebarThreadSortOrder, sortedProjectKeys, - threadsByProjectKey, + sortedThreadKeysByLogicalProject, ], ); const threadJumpCommandByKey = useMemo(() => { @@ -210,10 +171,30 @@ function useSidebarKeyboardController(input: { threadJumpLabelsRef.current = threadJumpLabelByKey; const showThreadJumpHintsRef = useRef(showThreadJumpHints); showThreadJumpHintsRef.current = showThreadJumpHints; + const latestKeyboardStateRef = useRef({ + keybindings, + navigateToThread, + platform, + routeThreadKey, + routeThreadRef, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + }); + latestKeyboardStateRef.current = { + keybindings, + navigateToThread, + platform, + routeThreadKey, + routeThreadRef, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + }; + const updateThreadJumpHintsVisibilityRef = useRef(updateThreadJumpHintsVisibility); + updateThreadJumpHintsVisibilityRef.current = updateThreadJumpHintsVisibility; useEffect(() => { const clearThreadJumpHints = () => { - updateThreadJumpHintsVisibility(false); + updateThreadJumpHintsVisibilityRef.current(false); }; const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => !event.metaKey && @@ -226,12 +207,32 @@ function useSidebarKeyboardController(input: { event.key !== "Shift" && !showThreadJumpHintsRef.current && threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + const getCurrentSidebarShortcutContext = () => { + const { routeThreadRef } = latestKeyboardStateRef.current; + return { + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }; + }; const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { if (shouldIgnoreThreadJumpHintUpdate(event)) { return; } + const { + keybindings, + navigateToThread, + platform, + routeThreadKey, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + } = latestKeyboardStateRef.current; const shortcutContext = getCurrentSidebarShortcutContext(); const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { platform, @@ -245,7 +246,7 @@ function useSidebarKeyboardController(input: { clearThreadJumpHints(); } } else { - updateThreadJumpHintsVisibility(true); + updateThreadJumpHintsVisibilityRef.current(true); } if (event.defaultPrevented || event.repeat) { @@ -301,6 +302,7 @@ function useSidebarKeyboardController(input: { return; } + const { keybindings, platform } = latestKeyboardStateRef.current; const shortcutContext = getCurrentSidebarShortcutContext(); const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { platform, @@ -310,7 +312,7 @@ function useSidebarKeyboardController(input: { clearThreadJumpHints(); return; } - updateThreadJumpHintsVisibility(true); + updateThreadJumpHintsVisibilityRef.current(true); }; const onWindowBlur = () => { @@ -326,17 +328,7 @@ function useSidebarKeyboardController(input: { window.removeEventListener("keyup", onWindowKeyUp); window.removeEventListener("blur", onWindowBlur); }; - }, [ - getCurrentSidebarShortcutContext, - keybindings, - navigateToThread, - platform, - routeThreadKey, - threadJumpCommandByKey, - threadJumpThreadKeys, - updateThreadJumpHintsVisibility, - visibleSidebarThreadKeys, - ]); + }, []); return threadJumpLabelByKey; } @@ -348,10 +340,14 @@ export const SidebarProjectOrderingController = memo( sidebarProjectSortOrder: SidebarProjectSortOrder; }) { const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; - const sidebarThreadSortSnapshots = useStore( + const orderingThreads = useStore( useMemo( - () => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(sidebarProjectSortOrder), - [sidebarProjectSortOrder], + () => + createSidebarProjectOrderingThreadSnapshotsSelector({ + physicalToLogicalKey, + sortOrder: sidebarProjectSortOrder, + }), + [physicalToLogicalKey, sidebarProjectSortOrder], ), ); const sortedProjectKeys = useMemo(() => { @@ -363,31 +359,10 @@ export const SidebarProjectOrderingController = memo( ...project, id: project.projectKey, })); - const sortableThreads = sidebarThreadSortSnapshots - .filter((thread) => thread.archivedAt === null) - .map((thread) => { - const physicalKey = scopedProjectKey( - scopeProjectRef(thread.environmentId, thread.projectId), - ); - return { - id: thread.id, - environmentId: thread.environmentId, - projectId: physicalToLogicalKey.get(physicalKey) ?? physicalKey, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestUserMessageAt: thread.latestUserMessageAt, - }; - }); - return sortProjectsForSidebar(sortableProjects, sortableThreads, sidebarProjectSortOrder).map( + return sortProjectsForSidebar(sortableProjects, orderingThreads, sidebarProjectSortOrder).map( (project) => project.id, ); - }, [ - physicalToLogicalKey, - sidebarProjectSortOrder, - sidebarProjects, - sidebarThreadSortSnapshots, - ]); + }, [orderingThreads, sidebarProjectSortOrder, sidebarProjects]); useEffect(() => { setSidebarProjectOrdering(sortedProjectKeys); @@ -399,16 +374,20 @@ export const SidebarProjectOrderingController = memo( export const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { navigateToThread: (threadRef: ScopedThreadRef) => void; + physicalToLogicalKey: ReadonlyMap; sidebarThreadSortOrder: SidebarThreadSortOrder; }) { - const { navigateToThread, sidebarThreadSortOrder } = props; + const { navigateToThread, physicalToLogicalKey, sidebarThreadSortOrder } = props; const sortedProjectKeys = useSidebarProjectKeys(); - const physicalToLogicalKey = useSidebarPhysicalToLogicalKey(); const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); - const sidebarThreadSortSnapshots = useStore( + const sortedThreadKeysByLogicalProject = useStore( useMemo( - () => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(sidebarThreadSortOrder), - [sidebarThreadSortOrder], + () => + createSidebarSortedThreadKeysByLogicalProjectSelector({ + physicalToLogicalKey, + threadSortOrder: sidebarThreadSortOrder, + }), + [physicalToLogicalKey, sidebarThreadSortOrder], ), ); const routeThreadRef = useParams({ @@ -426,15 +405,13 @@ export const SidebarKeyboardController = memo(function SidebarKeyboardController const platform = navigator.platform; const threadJumpLabelByKey = useSidebarKeyboardController({ sortedProjectKeys, - sidebarThreadSortSnapshots, - physicalToLogicalKey, + sortedThreadKeysByLogicalProject, expandedThreadListsByProject, routeThreadRef, routeThreadKey, platform, keybindings, navigateToThread, - sidebarThreadSortOrder, }); useEffect(() => { diff --git a/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts index 504c1f0097..4e2f57eae5 100644 --- a/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts +++ b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts @@ -1,8 +1,17 @@ +import type { ModelSelection, ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import { deriveLogicalProjectKey, type LogicalProjectKey } from "../../logicalProject"; -import type { Project } from "../../types"; -import { sidebarProjectSnapshotsEqual, type SidebarProjectSnapshot } from "./sidebarViewStore"; +import type { Project, ProjectScript } from "../../types"; + +export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; + +export type SidebarProjectSnapshot = Project & { + projectKey: LogicalProjectKey; + environmentPresence: EnvironmentPresence; + memberProjectRefs: readonly ScopedProjectRef[]; + remoteEnvironmentLabels: readonly string[]; +}; type SavedEnvironmentRegistryEntry = { label?: string | null; @@ -14,6 +23,78 @@ type SavedEnvironmentRuntimeEntry = { } | null; } | null; +function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function refsEqual(left: readonly ScopedProjectRef[], right: readonly ScopedProjectRef[]): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.projectId === right[index]?.projectId, + ) + ); +} + +function modelSelectionsEqual(left: ModelSelection | null, right: ModelSelection | null): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + + return ( + left.provider === right.provider && + left.model === right.model && + JSON.stringify(left.options ?? null) === JSON.stringify(right.options ?? null) + ); +} + +function projectScriptsEqual( + left: readonly ProjectScript[], + right: readonly ProjectScript[], +): boolean { + return ( + left.length === right.length && + left.every((script, index) => { + const other = right[index]; + return ( + other !== undefined && + script.id === other.id && + script.name === other.name && + script.command === other.command && + script.icon === other.icon && + script.runOnWorktreeCreate === other.runOnWorktreeCreate + ); + }) + ); +} + +export function sidebarProjectSnapshotsEqual( + left: SidebarProjectSnapshot | undefined, + right: SidebarProjectSnapshot, +): boolean { + return ( + left !== undefined && + left.id === right.id && + left.environmentId === right.environmentId && + left.name === right.name && + left.cwd === right.cwd && + left.repositoryIdentity === right.repositoryIdentity && + modelSelectionsEqual(left.defaultModelSelection, right.defaultModelSelection) && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + projectScriptsEqual(left.scripts, right.scripts) && + left.projectKey === right.projectKey && + left.environmentPresence === right.environmentPresence && + refsEqual(left.memberProjectRefs, right.memberProjectRefs) && + stringArraysEqual(left.remoteEnvironmentLabels, right.remoteEnvironmentLabels) + ); +} + export function buildSidebarPhysicalToLogicalKeyMap( orderedProjects: readonly Project[], ): ReadonlyMap { diff --git a/apps/web/src/components/sidebar/sidebarSelectors.ts b/apps/web/src/components/sidebar/sidebarSelectors.ts index ac6c903715..e31208e4b1 100644 --- a/apps/web/src/components/sidebar/sidebarSelectors.ts +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -16,19 +16,20 @@ import type { AppState, EnvironmentState } from "../../store"; import type { SidebarThreadSummary } from "../../types"; import type { LogicalProjectKey } from "../../logicalProject"; -export interface SidebarThreadSortSnapshot { +export interface SidebarProjectOrderingThreadSnapshot { id: ThreadId; environmentId: EnvironmentId; - projectId: ProjectId; + projectId: LogicalProjectKey; createdAt: string; archivedAt: string | null; updatedAt?: string | undefined; latestUserMessageAt: string | null; } -const EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS: SidebarThreadSortSnapshot[] = []; +const EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS: SidebarProjectOrderingThreadSnapshot[] = []; const EMPTY_PROJECT_THREAD_KEYS: string[] = []; const EMPTY_PROJECT_THREAD_STATUS_INPUTS: ProjectThreadStatusInput[] = []; +const EMPTY_SORTED_THREAD_KEYS_BY_LOGICAL_PROJECT = new Map(); function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); @@ -197,40 +198,47 @@ function includeUpdatedSortFields( return sortOrder === "updated_at"; } -export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector( - sortOrder: SidebarProjectSortOrder | SidebarThreadSortOrder, -): (state: AppState) => SidebarThreadSortSnapshot[] { - let previousResult = EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS; - let previousEntries = new Map(); +export function createSidebarProjectOrderingThreadSnapshotsSelector(input: { + physicalToLogicalKey: ReadonlyMap; + sortOrder: SidebarProjectSortOrder; +}): (state: AppState) => readonly SidebarProjectOrderingThreadSnapshot[] { + let previousResult: + | readonly SidebarProjectOrderingThreadSnapshot[] + | SidebarProjectOrderingThreadSnapshot[] = EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS; + let previousEntries = new Map(); return (state) => { - if (sortOrder === "manual") { - previousEntries = new Map(); - previousResult = EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS; + if (input.sortOrder === "manual") { + previousEntries = new Map(); + previousResult = EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS; return previousResult; } - const nextEntries = new Map(); - const nextResult: SidebarThreadSortSnapshot[] = []; + const watchUpdatedFields = includeUpdatedSortFields(input.sortOrder); + const nextEntries = new Map(); + const nextResult: SidebarProjectOrderingThreadSnapshot[] = []; let changed = false; - const watchUpdatedFields = includeUpdatedSortFields(sortOrder); for (const [environmentId, environmentState] of Object.entries( state.environmentStateById, ) as Array<[EnvironmentId, EnvironmentState]>) { for (const threadId of environmentState.threadIds) { const summary = environmentState.sidebarThreadSummaryById[threadId]; - if (!summary || summary.environmentId !== environmentId) { + if (!summary || summary.environmentId !== environmentId || summary.archivedAt !== null) { continue; } + const physicalKey = scopedProjectKey( + scopeProjectRef(summary.environmentId, summary.projectId), + ); + const logicalProjectKey = input.physicalToLogicalKey.get(physicalKey) ?? physicalKey; const entryKey = `${environmentId}:${threadId}`; const previousEntry = previousEntries.get(entryKey); if ( previousEntry && previousEntry.id === summary.id && previousEntry.environmentId === summary.environmentId && - previousEntry.projectId === summary.projectId && + previousEntry.projectId === logicalProjectKey && previousEntry.createdAt === summary.createdAt && previousEntry.archivedAt === summary.archivedAt && (!watchUpdatedFields || @@ -245,10 +253,10 @@ export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector( continue; } - const snapshot: SidebarThreadSortSnapshot = { + const snapshot: SidebarProjectOrderingThreadSnapshot = { id: summary.id, environmentId: summary.environmentId, - projectId: summary.projectId, + projectId: logicalProjectKey, createdAt: summary.createdAt, archivedAt: summary.archivedAt, updatedAt: summary.updatedAt, @@ -270,7 +278,77 @@ export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector( } previousEntries = nextEntries; - previousResult = nextResult.length === 0 ? EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS : nextResult; + previousResult = nextResult.length === 0 ? EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS : nextResult; + return previousResult; + }; +} + +export function createSidebarSortedThreadKeysByLogicalProjectSelector(input: { + physicalToLogicalKey: ReadonlyMap; + threadSortOrder: SidebarThreadSortOrder; +}): (state: AppState) => ReadonlyMap { + let previousResult = EMPTY_SORTED_THREAD_KEYS_BY_LOGICAL_PROJECT; + + return (state) => { + const groupedEntries = new Map(); + for (const [environmentId, environmentState] of Object.entries( + state.environmentStateById, + ) as Array<[EnvironmentId, EnvironmentState]>) { + for (const threadId of environmentState.threadIds) { + const summary = environmentState.sidebarThreadSummaryById[threadId]; + if (!summary || summary.environmentId !== environmentId || summary.archivedAt !== null) { + continue; + } + + const physicalKey = scopedProjectKey( + scopeProjectRef(summary.environmentId, summary.projectId), + ); + const logicalProjectKey = input.physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const projectEntries = groupedEntries.get(logicalProjectKey); + const entry: ProjectThreadRenderEntry = { + threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), + id: summary.id, + environmentId: summary.environmentId, + projectId: logicalProjectKey, + createdAt: summary.createdAt, + archivedAt: summary.archivedAt, + updatedAt: summary.updatedAt, + latestUserMessageAt: summary.latestUserMessageAt, + }; + if (projectEntries) { + projectEntries.push(entry); + } else { + groupedEntries.set(logicalProjectKey, [entry]); + } + } + } + + const nextResult = new Map(); + let changed = previousResult.size !== groupedEntries.size; + + for (const [projectKey, entries] of groupedEntries) { + const nextThreadKeys = sortThreadsForSidebar(entries, input.threadSortOrder).map( + (thread) => thread.threadKey, + ); + const previousThreadKeys = previousResult.get(projectKey); + if (previousThreadKeys && stringArraysEqual(previousThreadKeys, nextThreadKeys)) { + nextResult.set(projectKey, previousThreadKeys); + continue; + } + + nextResult.set( + projectKey, + nextThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : nextThreadKeys, + ); + changed = true; + } + + if (!changed) { + return previousResult; + } + + previousResult = + nextResult.size === 0 ? EMPTY_SORTED_THREAD_KEYS_BY_LOGICAL_PROJECT : nextResult; return previousResult; }; } diff --git a/apps/web/src/components/sidebar/sidebarViewStore.ts b/apps/web/src/components/sidebar/sidebarViewStore.ts index 5821d57e9f..88beb0b297 100644 --- a/apps/web/src/components/sidebar/sidebarViewStore.ts +++ b/apps/web/src/components/sidebar/sidebarViewStore.ts @@ -1,23 +1,10 @@ import { useCallback } from "react"; -import type { ScopedProjectRef } from "@t3tools/contracts"; import { useStore as useZustandStore } from "zustand"; import { createStore } from "zustand/vanilla"; import type { LogicalProjectKey } from "../../logicalProject"; -import type { Project } from "../../types"; - -export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; - -export type SidebarProjectSnapshot = Project & { - projectKey: LogicalProjectKey; - environmentPresence: EnvironmentPresence; - memberProjectRefs: readonly ScopedProjectRef[]; - remoteEnvironmentLabels: readonly string[]; -}; interface SidebarTransientState { sortedProjectKeys: readonly LogicalProjectKey[]; - projectSnapshotByKey: ReadonlyMap; - physicalToLogicalKey: ReadonlyMap; activeRouteThreadKey: string | null; activeRouteProjectKey: LogicalProjectKey | null; threadJumpLabelByKey: ReadonlyMap; @@ -26,76 +13,34 @@ interface SidebarTransientState { const EMPTY_THREAD_JUMP_LABELS = new Map(); const EMPTY_SIDEBAR_PROJECT_KEYS: LogicalProjectKey[] = []; -const EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY = new Map(); const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); const sidebarViewStore = createStore(() => ({ sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, - projectSnapshotByKey: EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY, - physicalToLogicalKey: new Map(), activeRouteThreadKey: null, activeRouteProjectKey: null, threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, })); -function refsEqual(left: readonly ScopedProjectRef[], right: readonly ScopedProjectRef[]): boolean { - return ( - left.length === right.length && - left.every( - (ref, index) => - ref.environmentId === right[index]?.environmentId && - ref.projectId === right[index]?.projectId, - ) - ); +export function resetSidebarViewState(): void { + sidebarViewStore.setState({ + sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, + activeRouteThreadKey: null, + activeRouteProjectKey: null, + threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, + expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, + }); } export function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } -export function sidebarProjectSnapshotsEqual( - left: SidebarProjectSnapshot | undefined, - right: SidebarProjectSnapshot, -): boolean { - return ( - left !== undefined && - left.id === right.id && - left.environmentId === right.environmentId && - left.name === right.name && - left.cwd === right.cwd && - left.repositoryIdentity === right.repositoryIdentity && - left.defaultModelSelection === right.defaultModelSelection && - left.createdAt === right.createdAt && - left.updatedAt === right.updatedAt && - left.scripts === right.scripts && - left.projectKey === right.projectKey && - left.environmentPresence === right.environmentPresence && - refsEqual(left.memberProjectRefs, right.memberProjectRefs) && - stringArraysEqual(left.remoteEnvironmentLabels, right.remoteEnvironmentLabels) - ); -} - export function useSidebarProjectKeys(): readonly LogicalProjectKey[] { return useZustandStore(sidebarViewStore, (state) => state.sortedProjectKeys); } -export function useSidebarPhysicalToLogicalKey(): ReadonlyMap { - return useZustandStore(sidebarViewStore, (state) => state.physicalToLogicalKey); -} - -export function useSidebarProjectSnapshot( - projectKey: LogicalProjectKey, -): SidebarProjectSnapshot | null { - return useZustandStore( - sidebarViewStore, - useCallback( - (state: SidebarTransientState) => state.projectSnapshotByKey.get(projectKey) ?? null, - [projectKey], - ), - ); -} - export function useSidebarProjectThreadListExpanded(projectKey: LogicalProjectKey): boolean { return useZustandStore( sidebarViewStore, @@ -171,24 +116,6 @@ export function useSidebarProjectActiveRouteThreadKey( ); } -export function syncSidebarProjectMappings(input: { - projectSnapshotByKey: ReadonlyMap; - physicalToLogicalKey: ReadonlyMap; -}): void { - const currentState = sidebarViewStore.getState(); - if ( - currentState.projectSnapshotByKey === input.projectSnapshotByKey && - currentState.physicalToLogicalKey === input.physicalToLogicalKey - ) { - return; - } - - sidebarViewStore.setState({ - projectSnapshotByKey: input.projectSnapshotByKey, - physicalToLogicalKey: input.physicalToLogicalKey, - }); -} - export function setSidebarProjectOrdering(sortedProjectKeys: readonly LogicalProjectKey[]): void { const currentState = sidebarViewStore.getState(); if (stringArraysEqual(currentState.sortedProjectKeys, sortedProjectKeys)) { From 96b3810dc9fd9069b60a66c25875b2955d8add2e Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:43:48 +0100 Subject: [PATCH 5/9] Tighten sidebar controller subscriptions --- apps/web/src/components/Sidebar.tsx | 18 +-- .../components/sidebar/sidebarControllers.tsx | 132 +++++++++--------- .../components/sidebar/sidebarSelectors.ts | 104 ++++++++++++++ 3 files changed, 175 insertions(+), 79 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 40f8fc03e0..9af5659621 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -115,7 +115,6 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, - shouldClearThreadSelectionOnMouseDown, sortThreadsForSidebar, ThreadStatusPill, } from "./Sidebar.logic"; @@ -131,6 +130,7 @@ import { THREAD_PREVIEW_LIMIT } from "./sidebar/sidebarConstants"; import { SidebarKeyboardController, SidebarProjectOrderingController, + SidebarSelectionController, } from "./sidebar/sidebarControllers"; import { collapseSidebarProjectThreadList, @@ -2282,7 +2282,6 @@ export default function Sidebar() { const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); - const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); @@ -2547,20 +2546,6 @@ export default function Sidebar() { }; }, []); - useEffect(() => { - const onMouseDown = (event: globalThis.MouseEvent) => { - if (selectedThreadCount === 0) return; - const target = event.target instanceof HTMLElement ? event.target : null; - if (!shouldClearThreadSelectionOnMouseDown(target)) return; - clearSelection(); - }; - - window.addEventListener("mousedown", onMouseDown); - return () => { - window.removeEventListener("mousedown", onMouseDown); - }; - }, [clearSelection, selectedThreadCount]); - useEffect(() => { if (!isElectron) return; const bridge = window.desktopBridge; @@ -2674,6 +2659,7 @@ export default function Sidebar() { physicalToLogicalKey={physicalToLogicalKey} sidebarProjectSortOrder={sidebarProjectSortOrder} /> + (); @@ -70,56 +71,53 @@ function buildThreadJumpLabelMap(input: { } function useSidebarKeyboardController(input: { + physicalToLogicalKey: ReadonlyMap; sortedProjectKeys: readonly LogicalProjectKey[]; - sortedThreadKeysByLogicalProject: ReadonlyMap; expandedThreadListsByProject: ReadonlySet; routeThreadRef: ScopedThreadRef | null; routeThreadKey: string | null; platform: string; keybindings: ReturnType; navigateToThread: (threadRef: ScopedThreadRef) => void; + threadSortOrder: SidebarThreadSortOrder; }) { const { + physicalToLogicalKey, sortedProjectKeys, - sortedThreadKeysByLogicalProject, expandedThreadListsByProject, routeThreadRef, routeThreadKey, platform, keybindings, navigateToThread, + threadSortOrder, } = input; - const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const projectExpandedStates = useUiStateStore( + useShallow((store) => + sortedProjectKeys.map((projectKey) => store.projectExpandedById[projectKey] ?? true), + ), + ); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const visibleSidebarThreadKeys = useMemo( - () => - sortedProjectKeys.flatMap((projectKey) => { - const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; - const projectExpanded = projectExpandedById[projectKey] ?? true; - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); - const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreadKeys - : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); - return pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - }), - [ - expandedThreadListsByProject, - projectExpandedById, - routeThreadKey, - sortedProjectKeys, - sortedThreadKeysByLogicalProject, - ], + const visibleSidebarThreadKeys = useStore( + useMemo( + () => + createSidebarVisibleThreadKeysSelector({ + expandedThreadListsByProject, + physicalToLogicalKey, + projectExpandedStates, + routeThreadKey, + sortedProjectKeys, + threadSortOrder, + }), + [ + expandedThreadListsByProject, + physicalToLogicalKey, + projectExpandedStates, + routeThreadKey, + sortedProjectKeys, + threadSortOrder, + ], + ), ); const threadJumpCommandByKey = useMemo(() => { const mapping = new Map>>(); @@ -333,6 +331,35 @@ function useSidebarKeyboardController(input: { return threadJumpLabelByKey; } +export const SidebarSelectionController = memo(function SidebarSelectionController() { + const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const selectedThreadCountRef = useRef(selectedThreadCount); + selectedThreadCountRef.current = selectedThreadCount; + const clearSelectionRef = useRef(clearSelection); + clearSelectionRef.current = clearSelection; + + useEffect(() => { + const onMouseDown = (event: globalThis.MouseEvent) => { + if (selectedThreadCountRef.current === 0) { + return; + } + const target = event.target instanceof HTMLElement ? event.target : null; + if (!shouldClearThreadSelectionOnMouseDown(target)) { + return; + } + clearSelectionRef.current(); + }; + + window.addEventListener("mousedown", onMouseDown); + return () => { + window.removeEventListener("mousedown", onMouseDown); + }; + }, []); + + return null; +}); + export const SidebarProjectOrderingController = memo( function SidebarProjectOrderingController(props: { sidebarProjects: readonly SidebarProjectSnapshot[]; @@ -340,29 +367,17 @@ export const SidebarProjectOrderingController = memo( sidebarProjectSortOrder: SidebarProjectSortOrder; }) { const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; - const orderingThreads = useStore( + const sortedProjectKeys = useStore( useMemo( () => - createSidebarProjectOrderingThreadSnapshotsSelector({ + createSidebarSortedProjectKeysSelector({ physicalToLogicalKey, + projects: sidebarProjects, sortOrder: sidebarProjectSortOrder, }), - [physicalToLogicalKey, sidebarProjectSortOrder], + [physicalToLogicalKey, sidebarProjectSortOrder, sidebarProjects], ), ); - const sortedProjectKeys = useMemo(() => { - if (sidebarProjectSortOrder === "manual") { - return sidebarProjects.map((project) => project.projectKey); - } - - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - return sortProjectsForSidebar(sortableProjects, orderingThreads, sidebarProjectSortOrder).map( - (project) => project.id, - ); - }, [orderingThreads, sidebarProjectSortOrder, sidebarProjects]); useEffect(() => { setSidebarProjectOrdering(sortedProjectKeys); @@ -380,16 +395,6 @@ export const SidebarKeyboardController = memo(function SidebarKeyboardController const { navigateToThread, physicalToLogicalKey, sidebarThreadSortOrder } = props; const sortedProjectKeys = useSidebarProjectKeys(); const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); - const sortedThreadKeysByLogicalProject = useStore( - useMemo( - () => - createSidebarSortedThreadKeysByLogicalProjectSelector({ - physicalToLogicalKey, - threadSortOrder: sidebarThreadSortOrder, - }), - [physicalToLogicalKey, sidebarThreadSortOrder], - ), - ); const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), @@ -404,14 +409,15 @@ export const SidebarKeyboardController = memo(function SidebarKeyboardController const keybindings = useServerKeybindings(); const platform = navigator.platform; const threadJumpLabelByKey = useSidebarKeyboardController({ + physicalToLogicalKey, sortedProjectKeys, - sortedThreadKeysByLogicalProject, expandedThreadListsByProject, routeThreadRef, routeThreadKey, platform, keybindings, navigateToThread, + threadSortOrder: sidebarThreadSortOrder, }); useEffect(() => { diff --git a/apps/web/src/components/sidebar/sidebarSelectors.ts b/apps/web/src/components/sidebar/sidebarSelectors.ts index e31208e4b1..d96323f3b1 100644 --- a/apps/web/src/components/sidebar/sidebarSelectors.ts +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -8,10 +8,12 @@ import { scopeThreadRef, } from "@t3tools/client-runtime"; import { + sortProjectsForSidebar, sortThreadsForSidebar, type ThreadStatusLatestTurnSnapshot, type ThreadStatusSessionSnapshot, } from "../Sidebar.logic"; +import { THREAD_PREVIEW_LIMIT } from "./sidebarConstants"; import type { AppState, EnvironmentState } from "../../store"; import type { SidebarThreadSummary } from "../../types"; import type { LogicalProjectKey } from "../../logicalProject"; @@ -30,6 +32,7 @@ const EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS: SidebarProjectOrderingThreadSnaps const EMPTY_PROJECT_THREAD_KEYS: string[] = []; const EMPTY_PROJECT_THREAD_STATUS_INPUTS: ProjectThreadStatusInput[] = []; const EMPTY_SORTED_THREAD_KEYS_BY_LOGICAL_PROJECT = new Map(); +const EMPTY_SORTED_PROJECT_KEYS: LogicalProjectKey[] = []; function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); @@ -283,6 +286,54 @@ export function createSidebarProjectOrderingThreadSnapshotsSelector(input: { }; } +export function createSidebarSortedProjectKeysSelector(input: { + physicalToLogicalKey: ReadonlyMap; + projects: ReadonlyArray<{ + projectKey: LogicalProjectKey; + name: string; + createdAt?: string | undefined; + updatedAt?: string | undefined; + }>; + sortOrder: SidebarProjectSortOrder; +}): (state: AppState) => readonly LogicalProjectKey[] { + let previousResult: readonly LogicalProjectKey[] = EMPTY_SORTED_PROJECT_KEYS; + const orderingThreadSelector = createSidebarProjectOrderingThreadSnapshotsSelector({ + physicalToLogicalKey: input.physicalToLogicalKey, + sortOrder: input.sortOrder, + }); + + return (state) => { + const manualProjectKeys = input.projects.map((project) => project.projectKey); + if (input.sortOrder === "manual") { + if (stringArraysEqual(previousResult, manualProjectKeys)) { + return previousResult; + } + previousResult = + manualProjectKeys.length === 0 ? EMPTY_SORTED_PROJECT_KEYS : manualProjectKeys; + return previousResult; + } + + const sortableProjects = input.projects.map((project) => ({ + id: project.projectKey, + name: project.name, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })); + const sortedProjectKeys = sortProjectsForSidebar( + sortableProjects, + orderingThreadSelector(state), + input.sortOrder, + ).map((project) => project.id); + + if (stringArraysEqual(previousResult, sortedProjectKeys)) { + return previousResult; + } + + previousResult = sortedProjectKeys.length === 0 ? EMPTY_SORTED_PROJECT_KEYS : sortedProjectKeys; + return previousResult; + }; +} + export function createSidebarSortedThreadKeysByLogicalProjectSelector(input: { physicalToLogicalKey: ReadonlyMap; threadSortOrder: SidebarThreadSortOrder; @@ -353,6 +404,59 @@ export function createSidebarSortedThreadKeysByLogicalProjectSelector(input: { }; } +export function createSidebarVisibleThreadKeysSelector(input: { + expandedThreadListsByProject: ReadonlySet; + physicalToLogicalKey: ReadonlyMap; + projectExpandedStates: readonly boolean[]; + sortedProjectKeys: readonly LogicalProjectKey[]; + threadSortOrder: SidebarThreadSortOrder; + routeThreadKey: string | null; +}): (state: AppState) => readonly string[] { + let previousResult: readonly string[] = EMPTY_PROJECT_THREAD_KEYS; + const sortedThreadKeysByLogicalProjectSelector = + createSidebarSortedThreadKeysByLogicalProjectSelector({ + physicalToLogicalKey: input.physicalToLogicalKey, + threadSortOrder: input.threadSortOrder, + }); + + return (state) => { + const sortedThreadKeysByLogicalProject = sortedThreadKeysByLogicalProjectSelector(state); + const nextVisibleThreadKeys: string[] = []; + + input.sortedProjectKeys.forEach((projectKey, index) => { + const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; + const projectExpanded = input.projectExpandedStates[index] ?? true; + const activeThreadKey = input.routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return; + } + + const isThreadListExpanded = input.expandedThreadListsByProject.has(projectKey); + const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; + const visibleProjectThreadKeys = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreadKeys + : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); + nextVisibleThreadKeys.push( + ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreadKeys), + ); + }); + + if (stringArraysEqual(previousResult, nextVisibleThreadKeys)) { + return previousResult; + } + + previousResult = + nextVisibleThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : nextVisibleThreadKeys; + return previousResult; + }; +} + export function createSidebarThreadRowSnapshotSelectorByRef( ref: ScopedThreadRef | null | undefined, ): (state: AppState) => SidebarThreadRowSnapshot | undefined { From 7ab18adaab5e7a94e51f36b81a791e03360aa317 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:59:11 +0100 Subject: [PATCH 6/9] Finalize sidebar key typing --- apps/web/src/components/Sidebar.tsx | 37 +++++--- .../components/sidebar/sidebarControllers.tsx | 89 +++++++++---------- .../components/sidebar/sidebarSelectors.ts | 54 ----------- .../components/sidebar/sidebarViewStore.ts | 23 ----- 4 files changed, 64 insertions(+), 139 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9af5659621..73b242620c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -119,6 +119,7 @@ import { ThreadStatusPill, } from "./Sidebar.logic"; import { + createSidebarSortedProjectKeysSelector, createSidebarProjectRenderStateSelector, createSidebarProjectThreadStatusInputsSelector, createSidebarThreadMetaSnapshotSelectorByRef, @@ -129,7 +130,6 @@ import { import { THREAD_PREVIEW_LIMIT } from "./sidebar/sidebarConstants"; import { SidebarKeyboardController, - SidebarProjectOrderingController, SidebarSelectionController, } from "./sidebar/sidebarControllers"; import { @@ -138,7 +138,6 @@ import { resetSidebarViewState, useSidebarIsActiveThread, useSidebarProjectActiveRouteThreadKey, - useSidebarProjectKeys, useSidebarProjectThreadListExpanded, useSidebarThreadJumpLabel, } from "./sidebar/sidebarViewStore"; @@ -156,6 +155,7 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; +import type { LogicalProjectKey } from "../logicalProject"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -1963,7 +1963,8 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { }); interface SidebarProjectsContentProps { - sidebarProjectByKey: ReadonlyMap; + sortedProjectKeys: readonly LogicalProjectKey[]; + sidebarProjectByKey: ReadonlyMap; showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; desktopUpdateButtonAction: "download" | "install" | "none"; @@ -2004,6 +2005,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( props: SidebarProjectsContentProps, ) { const { + sortedProjectKeys, sidebarProjectByKey, showArm64IntelBuildWarning, arm64IntelBuildWarningDescription, @@ -2040,8 +2042,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( attachProjectListAutoAnimateRef, projectsLength, } = props; - const sortedProjectKeys = useSidebarProjectKeys(); - const handleProjectSortOrderChange = useCallback( (sortOrder: SidebarProjectSortOrder) => { updateSettings({ sidebarProjectSortOrder: sortOrder }); @@ -2304,8 +2304,8 @@ export default function Sidebar() { ); const previousSidebarProjectSnapshotByKeyRef = useRef< - ReadonlyMap - >(new Map()); + ReadonlyMap + >(new Map()); const sidebarProjects = useMemo(() => { const { projectSnapshotByKey, sidebarProjects: nextSidebarProjects } = buildSidebarProjectSnapshots({ @@ -2325,9 +2325,23 @@ export default function Sidebar() { ]); const sidebarProjectByKey = useMemo( - () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + () => + new Map( + sidebarProjects.map((project) => [project.projectKey, project] as const), + ), [sidebarProjects], ); + const sortedProjectKeys = useStore( + useMemo( + () => + createSidebarSortedProjectKeysSelector({ + physicalToLogicalKey, + projects: sidebarProjects, + sortOrder: sidebarProjectSortOrder, + }), + [physicalToLogicalKey, sidebarProjectSortOrder, sidebarProjects], + ), + ); const focusMostRecentThreadForProject = useCallback( (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { const physicalKey = scopedProjectKey( @@ -2654,15 +2668,11 @@ export default function Sidebar() { return ( <> - @@ -2671,6 +2681,7 @@ export default function Sidebar() { ) : ( <> (); @@ -98,27 +95,47 @@ function useSidebarKeyboardController(input: { ), ); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const visibleSidebarThreadKeys = useStore( + const sortedThreadKeysByLogicalProject = useStore( useMemo( () => - createSidebarVisibleThreadKeysSelector({ - expandedThreadListsByProject, + createSidebarSortedThreadKeysByLogicalProjectSelector({ physicalToLogicalKey, - projectExpandedStates, - routeThreadKey, - sortedProjectKeys, threadSortOrder, }), - [ - expandedThreadListsByProject, - physicalToLogicalKey, - projectExpandedStates, - routeThreadKey, - sortedProjectKeys, - threadSortOrder, - ], + [physicalToLogicalKey, threadSortOrder], ), ); + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjectKeys.flatMap((projectKey, index) => { + const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; + const projectExpanded = projectExpandedStates[index] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + + const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); + const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; + const previewThreadKeys = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreadKeys + : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); + return pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreadKeys; + }), + [ + expandedThreadListsByProject, + projectExpandedStates, + routeThreadKey, + sortedProjectKeys, + sortedThreadKeysByLogicalProject, + ], + ); const threadJumpCommandByKey = useMemo(() => { const mapping = new Map>>(); for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { @@ -360,40 +377,14 @@ export const SidebarSelectionController = memo(function SidebarSelectionControll return null; }); -export const SidebarProjectOrderingController = memo( - function SidebarProjectOrderingController(props: { - sidebarProjects: readonly SidebarProjectSnapshot[]; - physicalToLogicalKey: ReadonlyMap; - sidebarProjectSortOrder: SidebarProjectSortOrder; - }) { - const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; - const sortedProjectKeys = useStore( - useMemo( - () => - createSidebarSortedProjectKeysSelector({ - physicalToLogicalKey, - projects: sidebarProjects, - sortOrder: sidebarProjectSortOrder, - }), - [physicalToLogicalKey, sidebarProjectSortOrder, sidebarProjects], - ), - ); - - useEffect(() => { - setSidebarProjectOrdering(sortedProjectKeys); - }, [sortedProjectKeys]); - - return null; - }, -); - export const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { navigateToThread: (threadRef: ScopedThreadRef) => void; physicalToLogicalKey: ReadonlyMap; + sortedProjectKeys: readonly LogicalProjectKey[]; sidebarThreadSortOrder: SidebarThreadSortOrder; }) { - const { navigateToThread, physicalToLogicalKey, sidebarThreadSortOrder } = props; - const sortedProjectKeys = useSidebarProjectKeys(); + const { navigateToThread, physicalToLogicalKey, sortedProjectKeys, sidebarThreadSortOrder } = + props; const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); const routeThreadRef = useParams({ strict: false, diff --git a/apps/web/src/components/sidebar/sidebarSelectors.ts b/apps/web/src/components/sidebar/sidebarSelectors.ts index d96323f3b1..da25d42344 100644 --- a/apps/web/src/components/sidebar/sidebarSelectors.ts +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -13,7 +13,6 @@ import { type ThreadStatusLatestTurnSnapshot, type ThreadStatusSessionSnapshot, } from "../Sidebar.logic"; -import { THREAD_PREVIEW_LIMIT } from "./sidebarConstants"; import type { AppState, EnvironmentState } from "../../store"; import type { SidebarThreadSummary } from "../../types"; import type { LogicalProjectKey } from "../../logicalProject"; @@ -404,59 +403,6 @@ export function createSidebarSortedThreadKeysByLogicalProjectSelector(input: { }; } -export function createSidebarVisibleThreadKeysSelector(input: { - expandedThreadListsByProject: ReadonlySet; - physicalToLogicalKey: ReadonlyMap; - projectExpandedStates: readonly boolean[]; - sortedProjectKeys: readonly LogicalProjectKey[]; - threadSortOrder: SidebarThreadSortOrder; - routeThreadKey: string | null; -}): (state: AppState) => readonly string[] { - let previousResult: readonly string[] = EMPTY_PROJECT_THREAD_KEYS; - const sortedThreadKeysByLogicalProjectSelector = - createSidebarSortedThreadKeysByLogicalProjectSelector({ - physicalToLogicalKey: input.physicalToLogicalKey, - threadSortOrder: input.threadSortOrder, - }); - - return (state) => { - const sortedThreadKeysByLogicalProject = sortedThreadKeysByLogicalProjectSelector(state); - const nextVisibleThreadKeys: string[] = []; - - input.sortedProjectKeys.forEach((projectKey, index) => { - const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; - const projectExpanded = input.projectExpandedStates[index] ?? true; - const activeThreadKey = input.routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return; - } - - const isThreadListExpanded = input.expandedThreadListsByProject.has(projectKey); - const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; - const visibleProjectThreadKeys = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreadKeys - : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); - nextVisibleThreadKeys.push( - ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreadKeys), - ); - }); - - if (stringArraysEqual(previousResult, nextVisibleThreadKeys)) { - return previousResult; - } - - previousResult = - nextVisibleThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : nextVisibleThreadKeys; - return previousResult; - }; -} - export function createSidebarThreadRowSnapshotSelectorByRef( ref: ScopedThreadRef | null | undefined, ): (state: AppState) => SidebarThreadRowSnapshot | undefined { diff --git a/apps/web/src/components/sidebar/sidebarViewStore.ts b/apps/web/src/components/sidebar/sidebarViewStore.ts index 88beb0b297..9e3d96b64b 100644 --- a/apps/web/src/components/sidebar/sidebarViewStore.ts +++ b/apps/web/src/components/sidebar/sidebarViewStore.ts @@ -4,7 +4,6 @@ import { createStore } from "zustand/vanilla"; import type { LogicalProjectKey } from "../../logicalProject"; interface SidebarTransientState { - sortedProjectKeys: readonly LogicalProjectKey[]; activeRouteThreadKey: string | null; activeRouteProjectKey: LogicalProjectKey | null; threadJumpLabelByKey: ReadonlyMap; @@ -12,11 +11,9 @@ interface SidebarTransientState { } const EMPTY_THREAD_JUMP_LABELS = new Map(); -const EMPTY_SIDEBAR_PROJECT_KEYS: LogicalProjectKey[] = []; const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); const sidebarViewStore = createStore(() => ({ - sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, activeRouteThreadKey: null, activeRouteProjectKey: null, threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, @@ -25,7 +22,6 @@ const sidebarViewStore = createStore(() => ({ export function resetSidebarViewState(): void { sidebarViewStore.setState({ - sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, activeRouteThreadKey: null, activeRouteProjectKey: null, threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, @@ -33,14 +29,6 @@ export function resetSidebarViewState(): void { }); } -export function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - -export function useSidebarProjectKeys(): readonly LogicalProjectKey[] { - return useZustandStore(sidebarViewStore, (state) => state.sortedProjectKeys); -} - export function useSidebarProjectThreadListExpanded(projectKey: LogicalProjectKey): boolean { return useZustandStore( sidebarViewStore, @@ -116,17 +104,6 @@ export function useSidebarProjectActiveRouteThreadKey( ); } -export function setSidebarProjectOrdering(sortedProjectKeys: readonly LogicalProjectKey[]): void { - const currentState = sidebarViewStore.getState(); - if (stringArraysEqual(currentState.sortedProjectKeys, sortedProjectKeys)) { - return; - } - - sidebarViewStore.setState({ - sortedProjectKeys, - }); -} - export function setSidebarKeyboardState(input: { activeRouteProjectKey: LogicalProjectKey | null; activeRouteThreadKey: string | null; From 52aed4df7984cbd10acba5080af5ce2421a21809 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:26:09 +0100 Subject: [PATCH 7/9] fixes from suggestions --- apps/web/src/components/Sidebar.tsx | 5027 ++++++++--------- .../components/sidebar/sidebarControllers.tsx | 720 +-- 2 files changed, 2868 insertions(+), 2879 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 73b242620c..874f7b83c1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,69 +1,69 @@ import { - ArchiveIcon, - ArrowUpDownIcon, - ChevronRightIcon, - CloudIcon, - FolderIcon, - GitPullRequestIcon, - PlusIcon, - SettingsIcon, - SquarePenIcon, - TerminalIcon, - TriangleAlertIcon, + ArchiveIcon, + ArrowUpDownIcon, + ChevronRightIcon, + CloudIcon, + FolderIcon, + GitPullRequestIcon, + PlusIcon, + SettingsIcon, + SquarePenIcon, + TerminalIcon, + TriangleAlertIcon, } from "lucide-react"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { - DndContext, - type DragCancelEvent, - type CollisionDetection, - PointerSensor, - type DragStartEvent, - closestCorners, - pointerWithin, - useSensor, - useSensors, - type DragEndEvent, + DndContext, + type DragCancelEvent, + type CollisionDetection, + PointerSensor, + type DragStartEvent, + closestCorners, + pointerWithin, + useSensor, + useSensors, + type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - DEFAULT_MODEL_BY_PROVIDER, - type DesktopUpdateState, - type EnvironmentId, - ProjectId, - type ScopedThreadRef, - type ThreadEnvMode, - ThreadId, - type GitStatusResult, + DEFAULT_MODEL_BY_PROVIDER, + type DesktopUpdateState, + type EnvironmentId, + ProjectId, + type ScopedThreadRef, + type ThreadEnvMode, + ThreadId, + type GitStatusResult, } from "@t3tools/contracts"; import { - parseScopedThreadKey, - scopedProjectKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, + parseScopedThreadKey, + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, } from "@t3tools/client-runtime"; import { Link, useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { - type SidebarProjectSortOrder, - type SidebarThreadSortOrder, + type SidebarProjectSortOrder, + type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { - selectProjectByRef, - selectProjectsAcrossEnvironments, - selectSidebarThreadSummaryByRef, - selectSidebarThreadsForProjectRefs, - selectThreadIdsByProjectRef, - selectThreadByRef, - useStore, + selectProjectByRef, + selectProjectsAcrossEnvironments, + selectSidebarThreadSummaryByRef, + selectSidebarThreadsForProjectRefs, + selectThreadIdsByProjectRef, + selectThreadByRef, + useStore, } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -79,2650 +79,2639 @@ import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { - getArm64IntelBuildWarningDescription, - getDesktopUpdateActionError, - getDesktopUpdateInstallConfirmationMessage, - isDesktopUpdateButtonDisabled, - resolveDesktopUpdateButtonAction, - shouldShowArm64IntelBuildWarning, - shouldToastDesktopUpdateActionResult, + getArm64IntelBuildWarningDescription, + getDesktopUpdateActionError, + getDesktopUpdateInstallConfirmationMessage, + isDesktopUpdateButtonDisabled, + resolveDesktopUpdateButtonAction, + shouldShowArm64IntelBuildWarning, + shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarSeparator, - SidebarTrigger, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarSeparator, + SidebarTrigger, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { - isContextMenuPointerDown, - resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, - resolveSidebarNewThreadEnvMode, - resolveThreadRowClassName, - resolveThreadStatusPill, - orderItemsByPreferredIds, - sortThreadsForSidebar, - ThreadStatusPill, + isContextMenuPointerDown, + resolveProjectStatusIndicator, + resolveSidebarNewThreadSeedContext, + resolveSidebarNewThreadEnvMode, + resolveThreadRowClassName, + resolveThreadStatusPill, + orderItemsByPreferredIds, + sortThreadsForSidebar, + ThreadStatusPill, } from "./Sidebar.logic"; import { - createSidebarSortedProjectKeysSelector, - createSidebarProjectRenderStateSelector, - createSidebarProjectThreadStatusInputsSelector, - createSidebarThreadMetaSnapshotSelectorByRef, - createSidebarThreadRowSnapshotSelectorByRef, - createSidebarThreadStatusInputSelectorByRef, - type ProjectThreadStatusInput, + createSidebarSortedProjectKeysSelector, + createSidebarProjectRenderStateSelector, + createSidebarProjectThreadStatusInputsSelector, + createSidebarThreadMetaSnapshotSelectorByRef, + createSidebarThreadRowSnapshotSelectorByRef, + createSidebarThreadStatusInputSelectorByRef, + type ProjectThreadStatusInput, } from "./sidebar/sidebarSelectors"; import { THREAD_PREVIEW_LIMIT } from "./sidebar/sidebarConstants"; import { - SidebarKeyboardController, - SidebarSelectionController, + SidebarKeyboardController, + SidebarSelectionController, } from "./sidebar/sidebarControllers"; import { - collapseSidebarProjectThreadList, - expandSidebarProjectThreadList, - resetSidebarViewState, - useSidebarIsActiveThread, - useSidebarProjectActiveRouteThreadKey, - useSidebarProjectThreadListExpanded, - useSidebarThreadJumpLabel, + collapseSidebarProjectThreadList, + expandSidebarProjectThreadList, + resetSidebarViewState, + useSidebarIsActiveThread, + useSidebarProjectActiveRouteThreadKey, + useSidebarProjectThreadListExpanded, + useSidebarThreadJumpLabel, } from "./sidebar/sidebarViewStore"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { - buildSidebarPhysicalToLogicalKeyMap, - buildSidebarProjectSnapshots, - type SidebarProjectSnapshot, + buildSidebarPhysicalToLogicalKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectSnapshot, } from "./sidebar/sidebarProjectSnapshots"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import type { LogicalProjectKey } from "../logicalProject"; const SIDEBAR_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", - manual: "Manual", + updated_at: "Last user message", + created_at: "Created at", + manual: "Manual", }; const SIDEBAR_THREAD_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", + updated_at: "Last user message", + created_at: "Created at", }; const SIDEBAR_LIST_ANIMATION_OPTIONS = { - duration: 180, - easing: "ease-out", + duration: 180, + easing: "ease-out", } as const; interface TerminalStatusIndicator { - label: "Terminal process running"; - colorClass: string; - pulse: boolean; + label: "Terminal process running"; + colorClass: string; + pulse: boolean; } interface PrStatusIndicator { - label: "PR open" | "PR closed" | "PR merged"; - colorClass: string; - tooltip: string; - url: string; + label: "PR open" | "PR closed" | "PR merged"; + colorClass: string; + tooltip: string; + url: string; } type ThreadPr = GitStatusResult["pr"]; function useSidebarThreadStatusInput( - threadRef: ScopedThreadRef | null, + threadRef: ScopedThreadRef | null, ): ProjectThreadStatusInput | undefined { - return useStore( - useMemo(() => createSidebarThreadStatusInputSelectorByRef(threadRef), [threadRef]), - ); + return useStore( + useMemo(() => createSidebarThreadStatusInputSelectorByRef(threadRef), [threadRef]), + ); } function ThreadStatusLabel({ - status, - compact = false, + status, + compact = false, }: { - status: ThreadStatusPill; - compact?: boolean; + status: ThreadStatusPill; + compact?: boolean; }) { - if (compact) { - return ( - - - {status.label} - - ); - } - - return ( - - - {status.label} - - ); + if (compact) { + return ( + + + {status.label} + + ); + } + + return ( + + + {status.label} + + ); } function terminalStatusFromRunningIds( - runningTerminalIds: string[], + runningTerminalIds: string[], ): TerminalStatusIndicator | null { - if (runningTerminalIds.length === 0) { - return null; - } - return { - label: "Terminal process running", - colorClass: "text-teal-600 dark:text-teal-300/90", - pulse: true, - }; + if (runningTerminalIds.length === 0) { + return null; + } + return { + label: "Terminal process running", + colorClass: "text-teal-600 dark:text-teal-300/90", + pulse: true, + }; } function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { - if (!pr) return null; - - if (pr.state === "open") { - return { - label: "PR open", - colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} PR open: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "closed") { - return { - label: "PR closed", - colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} PR closed: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "merged") { - return { - label: "PR merged", - colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} PR merged: ${pr.title}`, - url: pr.url, - }; - } - return null; + if (!pr) return null; + + if (pr.state === "open") { + return { + label: "PR open", + colorClass: "text-emerald-600 dark:text-emerald-300/90", + tooltip: `#${pr.number} PR open: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "closed") { + return { + label: "PR closed", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltip: `#${pr.number} PR closed: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "merged") { + return { + label: "PR merged", + colorClass: "text-violet-600 dark:text-violet-300/90", + tooltip: `#${pr.number} PR merged: ${pr.title}`, + url: pr.url, + }; + } + return null; } function resolveThreadPr( - threadBranch: string | null, - gitStatus: GitStatusResult | null, + threadBranch: string | null, + gitStatus: GitStatusResult | null, ): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { - return null; - } + if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + return null; + } - return gitStatus.pr ?? null; + return gitStatus.pr ?? null; } const SidebarThreadMetaCluster = memo(function SidebarThreadMetaCluster(props: { - appSettingsConfirmThreadArchive: boolean; - confirmArchiveButtonRef: React.RefObject; - handleArchiveImmediateClick: (event: React.MouseEvent) => void; - handleConfirmArchiveClick: (event: React.MouseEvent) => void; - handleStartArchiveConfirmation: (event: React.MouseEvent) => void; - isConfirmingArchive: boolean; - isHighlighted: boolean; - isRemoteThread: boolean; - stopPropagationOnPointerDown: (event: React.PointerEvent) => void; - threadEnvironmentLabel: string | null; - threadId: ThreadId; - threadKey: string; - threadRef: ScopedThreadRef; - threadTitle: string; + appSettingsConfirmThreadArchive: boolean; + confirmArchiveButtonRef: React.RefObject; + handleArchiveImmediateClick: (event: React.MouseEvent) => void; + handleConfirmArchiveClick: (event: React.MouseEvent) => void; + handleStartArchiveConfirmation: (event: React.MouseEvent) => void; + isConfirmingArchive: boolean; + isHighlighted: boolean; + isRemoteThread: boolean; + stopPropagationOnPointerDown: (event: React.PointerEvent) => void; + threadEnvironmentLabel: string | null; + threadId: ThreadId; + threadKey: string; + threadRef: ScopedThreadRef; + threadTitle: string; }) { - const { - appSettingsConfirmThreadArchive, - confirmArchiveButtonRef, - handleArchiveImmediateClick, - handleConfirmArchiveClick, - handleStartArchiveConfirmation, - isConfirmingArchive, - isHighlighted, - isRemoteThread, - stopPropagationOnPointerDown, - threadEnvironmentLabel, - threadId, - threadKey, - threadRef, - threadTitle, - } = props; - const jumpLabel = useSidebarThreadJumpLabel(threadKey); - const metaSnapshot = useStore( - useMemo(() => createSidebarThreadMetaSnapshotSelectorByRef(threadRef), [threadRef]), - ); - const isThreadRunning = metaSnapshot?.isRunning ?? false; - const hidden = isConfirmingArchive && !isThreadRunning; - const relativeTimestamp = useMemo( - () => (metaSnapshot ? formatRelativeTimeLabel(metaSnapshot.activityTimestamp) : null), - [metaSnapshot], - ); - const isConfirmingArchiveVisible = isConfirmingArchive && !isThreadRunning; - - const archiveControl = useMemo(() => { - if (isConfirmingArchiveVisible) { - return ( - - ); - } - - if (isThreadRunning) { - return null; - } - - if (appSettingsConfirmThreadArchive) { - return ( -
- -
- ); - } - - return ( - - - - - } - /> - Archive - - ); - }, [ - appSettingsConfirmThreadArchive, - confirmArchiveButtonRef, - handleArchiveImmediateClick, - handleConfirmArchiveClick, - handleStartArchiveConfirmation, - isConfirmingArchiveVisible, - isThreadRunning, - stopPropagationOnPointerDown, - threadId, - threadTitle, - ]); - - return ( - <> - {archiveControl} - - - ); + const { + appSettingsConfirmThreadArchive, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchive, + isHighlighted, + isRemoteThread, + stopPropagationOnPointerDown, + threadEnvironmentLabel, + threadId, + threadKey, + threadRef, + threadTitle, + } = props; + const jumpLabel = useSidebarThreadJumpLabel(threadKey); + const metaSnapshot = useStore( + useMemo(() => createSidebarThreadMetaSnapshotSelectorByRef(threadRef), [threadRef]), + ); + const isThreadRunning = metaSnapshot?.isRunning ?? false; + const hidden = isConfirmingArchive && !isThreadRunning; + const relativeTimestamp = useMemo( + () => (metaSnapshot ? formatRelativeTimeLabel(metaSnapshot.activityTimestamp) : null), + [metaSnapshot], + ); + const isConfirmingArchiveVisible = isConfirmingArchive && !isThreadRunning; + + const archiveControl = useMemo(() => { + if (isConfirmingArchiveVisible) { + return ( + + ); + } + + if (isThreadRunning) { + return null; + } + + if (appSettingsConfirmThreadArchive) { + return ( +
+ +
+ ); + } + + return ( + + + + + } + /> + Archive + + ); + }, [ + appSettingsConfirmThreadArchive, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchiveVisible, + isThreadRunning, + stopPropagationOnPointerDown, + threadId, + threadTitle, + ]); + + return ( + <> + {archiveControl} + + + ); }); const SidebarThreadStatusIndicator = memo(function SidebarThreadStatusIndicator(props: { - threadKey: string; - threadRef: ScopedThreadRef; + threadKey: string; + threadRef: ScopedThreadRef; }) { - const { threadKey, threadRef } = props; - const statusInput = useSidebarThreadStatusInput(threadRef); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const threadStatus = useMemo( - () => - statusInput - ? resolveThreadStatusPill({ - thread: { - ...statusInput, - lastVisitedAt, - }, - }) - : null, - [lastVisitedAt, statusInput], - ); - - return threadStatus ? : null; + const { threadKey, threadRef } = props; + const statusInput = useSidebarThreadStatusInput(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const threadStatus = useMemo( + () => + statusInput + ? resolveThreadStatusPill({ + thread: { + ...statusInput, + lastVisitedAt, + }, + }) + : null, + [lastVisitedAt, statusInput], + ); + + return threadStatus ? : null; }); const SidebarThreadTerminalStatusIndicator = memo( - function SidebarThreadTerminalStatusIndicator(props: { threadRef: ScopedThreadRef }) { - const { threadRef } = props; - const runningTerminalIds = useTerminalStateStore( - (state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, - ); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - - return terminalStatus ? ( - - - - ) : null; - }, + function SidebarThreadTerminalStatusIndicator(props: { threadRef: ScopedThreadRef }) { + const { threadRef } = props; + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, + ); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + + return terminalStatus ? ( + + + + ) : null; + }, ); interface SidebarThreadRowProps { - threadKey: string; - project: SidebarProjectSnapshot; + threadKey: string; + project: SidebarProjectSnapshot; } const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { - const { threadKey, project } = props; - const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); - const threadSortOrder = useSettings( - (settings) => settings.sidebarThreadSortOrder, - ); - const appSettingsConfirmThreadDelete = useSettings( - (settings) => settings.confirmThreadDelete, - ); - const appSettingsConfirmThreadArchive = useSettings( - (settings) => settings.confirmThreadArchive, - ); - const router = useRouter(); - const { archiveThread, deleteThread } = useThreadActions(); - const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); - const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); - const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); - const clearSelection = useThreadSelectionStore((state) => state.clearSelection); - const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); - const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); - const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ - threadId: ThreadId; - }>({ - onCopy: (ctx) => { - toastManager.add({ - type: "success", - title: "Thread ID copied", - description: ctx.threadId, - }); - }, - onError: (error) => { - toastManager.add({ - type: "error", - title: "Failed to copy thread ID", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }, - }); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ - path: string; - }>({ - onCopy: (ctx) => { - toastManager.add({ - type: "success", - title: "Path copied", - description: ctx.path, - }); - }, - onError: (error) => { - toastManager.add({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }, - }); - const [isRenaming, setIsRenaming] = useState(false); - const [renamingTitle, setRenamingTitle] = useState(""); - const [isConfirmingArchive, setIsConfirmingArchive] = useState(false); - const renamingCommittedRef = useRef(false); - const renamingInputRef = useRef(null); - const confirmArchiveButtonRef = useRef(null); - if (!threadRef) { - return null; - } - const thread = useStore( - useMemo(() => createSidebarThreadRowSnapshotSelectorByRef(threadRef), [threadRef]), - ); - const isActive = useSidebarIsActiveThread(threadKey); - if (!thread) { - return null; - } - const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); - const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = useSavedEnvironmentRuntimeStore( - (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, - ); - const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( - (s) => s.byId[thread.environmentId]?.label ?? null, - ); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") - : null; - // For grouped projects, the thread may belong to a different environment - // than the representative project. Look up the thread's own project cwd - // so git status (and thus PR detection) queries the correct path. - const threadProjectCwd = useStore( - useMemo( - () => (state: import("../store").AppState) => - selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? - null, - [thread.environmentId, thread.projectId], - ), - ); - const gitCwd = thread.worktreePath ?? threadProjectCwd ?? project?.cwd ?? null; - const gitStatus = useGitStatus({ - environmentId: thread.environmentId, - cwd: thread.branch != null ? gitCwd : null, - }); - const isHighlighted = isActive || isSelected; - const pr = resolveThreadPr(thread.branch, gitStatus.data); - const prStatus = prStatusIndicator(pr); - const clearConfirmingArchive = useCallback(() => { - setIsConfirmingArchive(false); - }, []); - const handleMouseLeave = useCallback(() => { - clearConfirmingArchive(); - }, [clearConfirmingArchive]); - const handleBlurCapture = useCallback( - (event: React.FocusEvent) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - clearConfirmingArchive(); - }); - }, - [clearConfirmingArchive], - ); - const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { - event.preventDefault(); - event.stopPropagation(); - - const api = readLocalApi(); - 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.", - }); - }); - }, []); - const navigateToThread = useCallback( - (targetThreadRef: ScopedThreadRef) => { - if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { - clearSelection(); - } - setSelectionAnchor(scopedThreadKey(targetThreadRef)); - void router.navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(targetThreadRef), - }); - }, - [clearSelection, router, setSelectionAnchor], - ); - const attemptArchiveThread = useCallback(async () => { - try { - await archiveThread(threadRef); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to archive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - }, [archiveThread, threadRef]); - const cancelRename = useCallback(() => { - setIsRenaming(false); - setRenamingTitle(""); - renamingCommittedRef.current = false; - renamingInputRef.current = null; - }, []); - const commitRename = useCallback(async () => { - const finishRename = () => { - setIsRenaming(false); - renamingCommittedRef.current = false; - renamingInputRef.current = null; - }; - - const trimmed = renamingTitle.trim(); - if (trimmed.length === 0) { - toastManager.add({ - type: "warning", - title: "Thread title cannot be empty", - }); - finishRename(); - return; - } - if (trimmed === thread.title) { - finishRename(); - return; - } - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) { - finishRename(); - return; - } - try { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadRef.threadId, - title: trimmed, - }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - finishRename(); - }, [renamingTitle, thread.title, threadRef]); - const handleMultiSelectContextMenu = useCallback( - async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; - if (threadKeys.length === 0) return; - const count = threadKeys.length; - - const clicked = await api.contextMenu.show( - [ - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], - position, - ); - - if (clicked === "mark-unread") { - const appState = useStore.getState(); - for (const selectedThreadKey of threadKeys) { - const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); - if (!selectedThreadRef) continue; - const selectedThread = selectSidebarThreadSummaryByRef(appState, selectedThreadRef); - markThreadUnread(selectedThreadKey, selectedThread?.latestTurn?.completedAt); - } - clearSelection(); - return; - } - - if (clicked !== "delete") return; - - if (appSettingsConfirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete ${count} thread${count === 1 ? "" : "s"}?`, - "This permanently clears conversation history for these threads.", - ].join("\n"), - ); - if (!confirmed) return; - } - - const deletedThreadKeys = new Set(threadKeys); - for (const selectedThreadKey of threadKeys) { - const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); - if (!selectedThreadRef) continue; - await deleteThread(selectedThreadRef, { deletedThreadKeys }); - } - removeFromSelection(threadKeys); - }, - [ - appSettingsConfirmThreadDelete, - clearSelection, - deleteThread, - markThreadUnread, - removeFromSelection, - ], - ); - const handleThreadContextMenu = useCallback( - async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - const threadWorkspacePath = thread.worktreePath ?? project?.cwd ?? null; - const currentThreadSummary = selectSidebarThreadSummaryByRef(useStore.getState(), threadRef); - const clicked = await api.contextMenu.show( - [ - { id: "rename", label: "Rename thread" }, - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy Path" }, - { id: "copy-thread-id", label: "Copy Thread ID" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, - ); - - if (clicked === "rename") { - setIsRenaming(true); - setRenamingTitle(thread.title); - renamingCommittedRef.current = false; - return; - } - - if (clicked === "mark-unread") { - markThreadUnread(threadKey, currentThreadSummary?.latestTurn?.completedAt); - return; - } - if (clicked === "copy-path") { - if (!threadWorkspacePath) { - toastManager.add({ - type: "error", - title: "Path unavailable", - description: "This thread does not have a workspace path to copy.", - }); - return; - } - copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); - return; - } - if (clicked === "copy-thread-id") { - copyThreadIdToClipboard(thread.id, { threadId: thread.id }); - return; - } - if (clicked !== "delete") return; - if (appSettingsConfirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ); - if (!confirmed) { - return; - } - } - await deleteThread(threadRef); - }, - [ - appSettingsConfirmThreadDelete, - copyPathToClipboard, - copyThreadIdToClipboard, - deleteThread, - markThreadUnread, - project?.cwd, - thread, - threadKey, - threadRef, - ], - ); - const handleRowClick = useCallback( - (event: React.MouseEvent) => { - const isMac = isMacPlatform(navigator.platform); - const isModClick = isMac ? event.metaKey : event.ctrlKey; - const isShiftClick = event.shiftKey; - const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size; - - if (isModClick) { - event.preventDefault(); - toggleThreadSelection(threadKey); - return; - } - - if (isShiftClick) { - event.preventDefault(); - const orderedProjectThreadKeys = project - ? sortThreadsForSidebar( - selectSidebarThreadsForProjectRefs( - useStore.getState(), - project.memberProjectRefs, - ).filter((projectThread) => projectThread.archivedAt === null), - threadSortOrder, - ).map((projectThread) => - scopedThreadKey(scopeThreadRef(projectThread.environmentId, projectThread.id)), - ) - : [threadKey]; - rangeSelectTo(threadKey, orderedProjectThreadKeys); - return; - } - - if (currentSelectionCount > 0) { - clearSelection(); - } - navigateToThread(threadRef); - }, - [ - clearSelection, - navigateToThread, - project, - rangeSelectTo, - threadKey, - threadRef, - threadSortOrder, - toggleThreadSelection, - ], - ); - const handleRowKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(threadRef); - }, - [navigateToThread, threadRef], - ); - const handleRowContextMenu = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - if (hasSelection && isSelected) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - return; - } - - if (hasSelection) { - clearSelection(); - } - void handleThreadContextMenu({ - x: event.clientX, - y: event.clientY, - }); - }, - [ - clearSelection, - handleMultiSelectContextMenu, - handleThreadContextMenu, - hasSelection, - isSelected, - ], - ); - const handlePrClick = useCallback( - (event: React.MouseEvent) => { - if (!prStatus) return; - openPrLink(event, prStatus.url); - }, - [openPrLink, prStatus], - ); - const handleRenameInputRef = useCallback( - (element: HTMLInputElement | null) => { - if (element && renamingInputRef.current !== element) { - renamingInputRef.current = element; - element.focus(); - element.select(); - } - }, - [renamingInputRef], - ); - const handleRenameInputChange = useCallback( - (event: React.ChangeEvent) => { - setRenamingTitle(event.target.value); - }, - [setRenamingTitle], - ); - const handleRenameInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(); - } else if (event.key === "Escape") { - event.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }, - [cancelRename, commitRename, renamingCommittedRef], - ); - const handleRenameInputBlur = useCallback(() => { - if (!renamingCommittedRef.current) { - void commitRename(); - } - }, [commitRename, renamingCommittedRef]); - const handleRenameInputClick = useCallback((event: React.MouseEvent) => { - event.stopPropagation(); - }, []); - const stopPropagationOnPointerDown = useCallback( - (event: React.PointerEvent) => { - event.stopPropagation(); - }, - [], - ); - const handleConfirmArchiveClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - clearConfirmingArchive(); - void attemptArchiveThread(); - }, - [attemptArchiveThread, clearConfirmingArchive], - ); - const handleStartArchiveConfirmation = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - setIsConfirmingArchive(true); - requestAnimationFrame(() => { - confirmArchiveButtonRef.current?.focus(); - }); - }, - [], - ); - const handleArchiveImmediateClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - void attemptArchiveThread(); - }, - [attemptArchiveThread], - ); - const rowButtonRender = useMemo(() =>
, []); - - return ( - - -
- {prStatus && ( - - - - - } - /> - {prStatus.tooltip} - - )} - - {isRenaming ? ( - - ) : ( - {thread.title} - )} -
-
- -
- -
-
-
-
- ); + const { threadKey, project } = props; + const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); + const threadSortOrder = useSettings( + (settings) => settings.sidebarThreadSortOrder, + ); + const appSettingsConfirmThreadDelete = useSettings( + (settings) => settings.confirmThreadDelete, + ); + const appSettingsConfirmThreadArchive = useSettings( + (settings) => settings.confirmThreadArchive, + ); + const router = useRouter(); + const { archiveThread, deleteThread } = useThreadActions(); + const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); + const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); + const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); + const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ + threadId: ThreadId; + }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: ctx.threadId, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ + path: string; + }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Path copied", + description: ctx.path, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); + const [isRenaming, setIsRenaming] = useState(false); + const [renamingTitle, setRenamingTitle] = useState(""); + const [isConfirmingArchive, setIsConfirmingArchive] = useState(false); + const renamingCommittedRef = useRef(false); + const renamingInputRef = useRef(null); + const confirmArchiveButtonRef = useRef(null); + + const thread = useStore( + useMemo(() => createSidebarThreadRowSnapshotSelectorByRef(threadRef), [threadRef]), + ); + const isActive = useSidebarIsActiveThread(threadKey); + if (!threadRef || !thread) { + return null; + } + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = useSavedEnvironmentRuntimeStore( + (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, + ); + const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( + (s) => s.byId[thread.environmentId]?.label ?? null, + ); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") + : null; + // For grouped projects, the thread may belong to a different environment + // than the representative project. Look up the thread's own project cwd + // so git status (and thus PR detection) queries the correct path. + const threadProjectCwd = useStore( + useMemo( + () => (state: import("../store").AppState) => + selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? + null, + [thread.environmentId, thread.projectId], + ), + ); + const gitCwd = thread.worktreePath ?? threadProjectCwd ?? project?.cwd ?? null; + const gitStatus = useGitStatus({ + environmentId: thread.environmentId, + cwd: thread.branch != null ? gitCwd : null, + }); + const isHighlighted = isActive || isSelected; + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr); + const clearConfirmingArchive = useCallback(() => { + setIsConfirmingArchive(false); + }, []); + const handleMouseLeave = useCallback(() => { + clearConfirmingArchive(); + }, [clearConfirmingArchive]); + const handleBlurCapture = useCallback( + (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + clearConfirmingArchive(); + }); + }, + [clearConfirmingArchive], + ); + const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + 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.", + }); + }); + }, []); + const navigateToThread = useCallback( + (targetThreadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(targetThreadRef)); + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(targetThreadRef), + }); + }, + [clearSelection, router, setSelectionAnchor], + ); + const attemptArchiveThread = useCallback(async () => { + try { + await archiveThread(threadRef); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, [archiveThread, threadRef]); + const cancelRename = useCallback(() => { + setIsRenaming(false); + setRenamingTitle(""); + renamingCommittedRef.current = false; + renamingInputRef.current = null; + }, []); + const commitRename = useCallback(async () => { + const finishRename = () => { + setIsRenaming(false); + renamingCommittedRef.current = false; + renamingInputRef.current = null; + }; + + const trimmed = renamingTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Thread title cannot be empty", + }); + finishRename(); + return; + } + if (trimmed === thread.title) { + finishRename(); + return; + } + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + finishRename(); + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: threadRef.threadId, + title: trimmed, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + finishRename(); + }, [renamingTitle, thread.title, threadRef]); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + if (threadKeys.length === 0) return; + const count = threadKeys.length; + + const clicked = await api.contextMenu.show( + [ + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ); + + if (clicked === "mark-unread") { + const appState = useStore.getState(); + for (const selectedThreadKey of threadKeys) { + const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); + if (!selectedThreadRef) continue; + const selectedThread = selectSidebarThreadSummaryByRef(appState, selectedThreadRef); + markThreadUnread(selectedThreadKey, selectedThread?.latestTurn?.completedAt); + } + clearSelection(); + return; + } + + if (clicked !== "delete") return; + + if (appSettingsConfirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ); + if (!confirmed) return; + } + + const deletedThreadKeys = new Set(threadKeys); + for (const selectedThreadKey of threadKeys) { + const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); + if (!selectedThreadRef) continue; + await deleteThread(selectedThreadRef, { deletedThreadKeys }); + } + removeFromSelection(threadKeys); + }, + [ + appSettingsConfirmThreadDelete, + clearSelection, + deleteThread, + markThreadUnread, + removeFromSelection, + ], + ); + const handleThreadContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadWorkspacePath = thread.worktreePath ?? project?.cwd ?? null; + const currentThreadSummary = selectSidebarThreadSummaryByRef(useStore.getState(), threadRef); + const clicked = await api.contextMenu.show( + [ + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy Path" }, + { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + + if (clicked === "rename") { + setIsRenaming(true); + setRenamingTitle(thread.title); + renamingCommittedRef.current = false; + return; + } + + if (clicked === "mark-unread") { + markThreadUnread(threadKey, currentThreadSummary?.latestTurn?.completedAt); + return; + } + if (clicked === "copy-path") { + if (!threadWorkspacePath) { + toastManager.add({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }); + return; + } + copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + return; + } + if (clicked === "copy-thread-id") { + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + return; + } + if (clicked !== "delete") return; + if (appSettingsConfirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + } + await deleteThread(threadRef); + }, + [ + appSettingsConfirmThreadDelete, + copyPathToClipboard, + copyThreadIdToClipboard, + deleteThread, + markThreadUnread, + project?.cwd, + thread, + threadKey, + threadRef, + ], + ); + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const isShiftClick = event.shiftKey; + const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size; + + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + + if (isShiftClick) { + event.preventDefault(); + const orderedProjectThreadKeys = project + ? sortThreadsForSidebar( + selectSidebarThreadsForProjectRefs( + useStore.getState(), + project.memberProjectRefs, + ).filter((projectThread) => projectThread.archivedAt === null), + threadSortOrder, + ).map((projectThread) => + scopedThreadKey(scopeThreadRef(projectThread.environmentId, projectThread.id)), + ) + : [threadKey]; + rangeSelectTo(threadKey, orderedProjectThreadKeys); + return; + } + + if (currentSelectionCount > 0) { + clearSelection(); + } + navigateToThread(threadRef); + }, + [ + clearSelection, + navigateToThread, + project, + rangeSelectTo, + threadKey, + threadRef, + threadSortOrder, + toggleThreadSelection, + ], + ); + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }, + [navigateToThread, threadRef], + ); + const handleRowContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (hasSelection && isSelected) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + return; + } + + if (hasSelection) { + clearSelection(); + } + void handleThreadContextMenu({ + x: event.clientX, + y: event.clientY, + }); + }, + [ + clearSelection, + handleMultiSelectContextMenu, + handleThreadContextMenu, + hasSelection, + isSelected, + ], + ); + const handlePrClick = useCallback( + (event: React.MouseEvent) => { + if (!prStatus) return; + openPrLink(event, prStatus.url); + }, + [openPrLink, prStatus], + ); + const handleRenameInputRef = useCallback( + (element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + } + }, + [renamingInputRef], + ); + const handleRenameInputChange = useCallback( + (event: React.ChangeEvent) => { + setRenamingTitle(event.target.value); + }, + [setRenamingTitle], + ); + const handleRenameInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(); + } else if (event.key === "Escape") { + event.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }, + [cancelRename, commitRename, renamingCommittedRef], + ); + const handleRenameInputBlur = useCallback(() => { + if (!renamingCommittedRef.current) { + void commitRename(); + } + }, [commitRename, renamingCommittedRef]); + const handleRenameInputClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + const stopPropagationOnPointerDown = useCallback( + (event: React.PointerEvent) => { + event.stopPropagation(); + }, + [], + ); + const handleConfirmArchiveClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearConfirmingArchive(); + void attemptArchiveThread(); + }, + [attemptArchiveThread, clearConfirmingArchive], + ); + const handleStartArchiveConfirmation = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsConfirmingArchive(true); + requestAnimationFrame(() => { + confirmArchiveButtonRef.current?.focus(); + }); + }, + [], + ); + const handleArchiveImmediateClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void attemptArchiveThread(); + }, + [attemptArchiveThread], + ); + const rowButtonRender = useMemo(() =>
, []); + + return ( + + +
+ {prStatus && ( + + + + + } + /> + {prStatus.tooltip} + + )} + + {isRenaming ? ( + + ) : ( + {thread.title} + )} +
+
+ +
+ +
+
+
+
+ ); }); interface SidebarProjectThreadListProps { - project: SidebarProjectSnapshot; - projectExpanded: boolean; - hasOverflowingThreads: boolean; - hiddenThreadKeys: readonly string[]; - renderedThreadKeys: readonly string[]; - showEmptyThreadState: boolean; - shouldShowThreadPanel: boolean; - isThreadListExpanded: boolean; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + project: SidebarProjectSnapshot; + projectExpanded: boolean; + hasOverflowingThreads: boolean; + hiddenThreadKeys: readonly string[]; + renderedThreadKeys: readonly string[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; + isThreadListExpanded: boolean; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; } const SidebarProjectThreadList = memo(function SidebarProjectThreadList( - props: SidebarProjectThreadListProps, + props: SidebarProjectThreadListProps, ) { - const { - project, - projectExpanded, - hasOverflowingThreads, - hiddenThreadKeys, - renderedThreadKeys, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - attachThreadListAutoAnimateRef, - } = props; - const showMoreButtonRender = useMemo(() => -
- } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - -
- ); + {project.environmentPresence === "remote-only" && ( + + + } + > + + + + Remote environment: {project.remoteEnvironmentLabels.join(", ")} + + + )} + + + + + } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + + + ); }); interface SidebarProjectItemProps { - project: SidebarProjectSnapshot; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - dragInProgressRef: React.RefObject; - suppressProjectClickAfterDragRef: React.RefObject; - suppressProjectClickForContextMenuRef: React.RefObject; - isManualProjectSorting: boolean; - dragHandleProps: SortableProjectHandleProps | null; + project: SidebarProjectSnapshot; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + isManualProjectSorting: boolean; + dragHandleProps: SortableProjectHandleProps | null; } const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { - const { - project, - attachThreadListAutoAnimateRef, - dragInProgressRef, - suppressProjectClickAfterDragRef, - suppressProjectClickForContextMenuRef, - isManualProjectSorting, - dragHandleProps, - } = props; - - return ( - <> - - - - - ); + const { + project, + attachThreadListAutoAnimateRef, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + isManualProjectSorting, + dragHandleProps, + } = props; + + return ( + <> + + + + + ); }); const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { - return ( - - - - ); + return ( + + + + ); }); function T3Wordmark() { - return ( - - - - ); + return ( + + + + ); } type SortableProjectHandleProps = Pick< - ReturnType, - "attributes" | "listeners" | "setActivatorNodeRef" + ReturnType, + "attributes" | "listeners" | "setActivatorNodeRef" >; function ProjectSortMenu({ - projectSortOrder, - threadSortOrder, - onProjectSortOrderChange, - onThreadSortOrderChange, + projectSortOrder, + threadSortOrder, + onProjectSortOrderChange, + onThreadSortOrderChange, }: { - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; - onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; + onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; }) { - return ( - - - - } - > - - - Sort projects - - - -
- Sort projects -
- { - onProjectSortOrderChange(value as SidebarProjectSortOrder); - }} - > - {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( - ([value, label]) => ( - - {label} - - ), - )} - -
- -
- Sort threads -
- { - onThreadSortOrderChange(value as SidebarThreadSortOrder); - }} - > - {( - Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> - ).map(([value, label]) => ( - - {label} - - ))} - -
-
-
- ); + return ( + + + + } + > + + + Sort projects + + + +
+ Sort projects +
+ { + onProjectSortOrderChange(value as SidebarProjectSortOrder); + }} + > + {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( + ([value, label]) => ( + + {label} + + ), + )} + +
+ +
+ Sort threads +
+ { + onThreadSortOrderChange(value as SidebarThreadSortOrder); + }} + > + {( + Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> + ).map(([value, label]) => ( + + {label} + + ))} + +
+
+
+ ); } function SortableProjectItem({ - projectId, - disabled = false, - children, + projectId, + disabled = false, + children, }: { - projectId: string; - disabled?: boolean; - children: (handleProps: SortableProjectHandleProps) => React.ReactNode; + projectId: string; + disabled?: boolean; + children: (handleProps: SortableProjectHandleProps) => React.ReactNode; }) { - const { - attributes, - listeners, - setActivatorNodeRef, - setNodeRef, - transform, - transition, - isDragging, - isOver, - } = useSortable({ id: projectId, disabled }); - return ( -
  • - {children({ attributes, listeners, setActivatorNodeRef })} -
  • - ); + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ id: projectId, disabled }); + return ( +
  • + {children({ attributes, listeners, setActivatorNodeRef })} +
  • + ); } const SidebarChromeHeader = memo(function SidebarChromeHeader({ - isElectron, + isElectron, }: { - isElectron: boolean; + isElectron: boolean; }) { - const wordmark = ( -
    - - - - - - Code - - - {APP_STAGE_LABEL} - - - } - /> - - Version {APP_VERSION} - - -
    - ); - - return isElectron ? ( - - {wordmark} - - ) : ( - {wordmark} - ); + const wordmark = ( +
    + + + + + + Code + + + {APP_STAGE_LABEL} + + + } + /> + + Version {APP_VERSION} + + +
    + ); + + return isElectron ? ( + + {wordmark} + + ) : ( + {wordmark} + ); }); const SidebarChromeFooter = memo(function SidebarChromeFooter() { - const navigate = useNavigate(); - const handleSettingsClick = useCallback(() => { - void navigate({ to: "/settings" }); - }, [navigate]); - - return ( - - - - - - - Settings - - - - - ); + const navigate = useNavigate(); + const handleSettingsClick = useCallback(() => { + void navigate({ to: "/settings" }); + }, [navigate]); + + return ( + + + + + + + Settings + + + + + ); }); interface SidebarProjectsContentProps { - sortedProjectKeys: readonly LogicalProjectKey[]; - sidebarProjectByKey: ReadonlyMap; - showArm64IntelBuildWarning: boolean; - arm64IntelBuildWarningDescription: string | null; - desktopUpdateButtonAction: "download" | "install" | "none"; - desktopUpdateButtonDisabled: boolean; - handleDesktopUpdateButtonClick: () => void; - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - updateSettings: ReturnType["updateSettings"]; - shouldShowProjectPathEntry: boolean; - handleStartAddProject: () => void; - isElectron: boolean; - isPickingFolder: boolean; - isAddingProject: boolean; - handlePickFolder: () => Promise; - addProjectInputRef: React.RefObject; - addProjectError: string | null; - newCwd: string; - setNewCwd: React.Dispatch>; - setAddProjectError: React.Dispatch>; - handleAddProject: () => void; - setAddingProject: React.Dispatch>; - canAddProject: boolean; - isManualProjectSorting: boolean; - projectDnDSensors: ReturnType; - projectCollisionDetection: CollisionDetection; - handleProjectDragStart: (event: DragStartEvent) => void; - handleProjectDragEnd: (event: DragEndEvent) => void; - handleProjectDragCancel: (event: DragCancelEvent) => void; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - dragInProgressRef: React.RefObject; - suppressProjectClickAfterDragRef: React.RefObject; - suppressProjectClickForContextMenuRef: React.RefObject; - attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; - projectsLength: number; + sortedProjectKeys: readonly LogicalProjectKey[]; + sidebarProjectByKey: ReadonlyMap; + showArm64IntelBuildWarning: boolean; + arm64IntelBuildWarningDescription: string | null; + desktopUpdateButtonAction: "download" | "install" | "none"; + desktopUpdateButtonDisabled: boolean; + handleDesktopUpdateButtonClick: () => void; + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + updateSettings: ReturnType["updateSettings"]; + shouldShowProjectPathEntry: boolean; + handleStartAddProject: () => void; + isElectron: boolean; + isPickingFolder: boolean; + isAddingProject: boolean; + handlePickFolder: () => Promise; + addProjectInputRef: React.RefObject; + addProjectError: string | null; + newCwd: string; + setNewCwd: React.Dispatch>; + setAddProjectError: React.Dispatch>; + handleAddProject: () => void; + setAddingProject: React.Dispatch>; + canAddProject: boolean; + isManualProjectSorting: boolean; + projectDnDSensors: ReturnType; + projectCollisionDetection: CollisionDetection; + handleProjectDragStart: (event: DragStartEvent) => void; + handleProjectDragEnd: (event: DragEndEvent) => void; + handleProjectDragCancel: (event: DragCancelEvent) => void; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; + projectsLength: number; } const SidebarProjectsContent = memo(function SidebarProjectsContent( - props: SidebarProjectsContentProps, + props: SidebarProjectsContentProps, ) { - const { - sortedProjectKeys, - sidebarProjectByKey, - showArm64IntelBuildWarning, - arm64IntelBuildWarningDescription, - desktopUpdateButtonAction, - desktopUpdateButtonDisabled, - handleDesktopUpdateButtonClick, - projectSortOrder, - threadSortOrder, - updateSettings, - shouldShowProjectPathEntry, - handleStartAddProject, - isElectron, - isPickingFolder, - isAddingProject, - handlePickFolder, - addProjectInputRef, - addProjectError, - newCwd, - setNewCwd, - setAddProjectError, - handleAddProject, - setAddingProject, - canAddProject, - isManualProjectSorting, - projectDnDSensors, - projectCollisionDetection, - handleProjectDragStart, - handleProjectDragEnd, - handleProjectDragCancel, - attachThreadListAutoAnimateRef, - dragInProgressRef, - suppressProjectClickAfterDragRef, - suppressProjectClickForContextMenuRef, - attachProjectListAutoAnimateRef, - projectsLength, - } = props; - const handleProjectSortOrderChange = useCallback( - (sortOrder: SidebarProjectSortOrder) => { - updateSettings({ sidebarProjectSortOrder: sortOrder }); - }, - [updateSettings], - ); - const handleThreadSortOrderChange = useCallback( - (sortOrder: SidebarThreadSortOrder) => { - updateSettings({ sidebarThreadSortOrder: sortOrder }); - }, - [updateSettings], - ); - const handleAddProjectInputChange = useCallback( - (event: React.ChangeEvent) => { - setNewCwd(event.target.value); - setAddProjectError(null); - }, - [setAddProjectError, setNewCwd], - ); - const handleAddProjectInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } - }, - [handleAddProject, setAddProjectError, setAddingProject], - ); - const handleBrowseForFolderClick = useCallback(() => { - void handlePickFolder(); - }, [handlePickFolder]); - - return ( - - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null} - -
    - - Projects - -
    - - - - } - > - - - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - - -
    -
    - {shouldShowProjectPathEntry && ( -
    - {isElectron && ( - - )} -
    - - -
    - {addProjectError && ( -

    - {addProjectError} -

    - )} -
    - )} - - {isManualProjectSorting ? ( - - - - {sortedProjectKeys.map((projectKey) => - (() => { - const project = sidebarProjectByKey.get(projectKey); - if (!project) { - return null; - } - return ( - - {(dragHandleProps) => ( - - )} - - ); - })(), - )} - - - - ) : ( - - {sortedProjectKeys.map((projectKey) => - (() => { - const project = sidebarProjectByKey.get(projectKey); - if (!project) { - return null; - } - return ( - - ); - })(), - )} - - )} - - {projectsLength === 0 && !shouldShowProjectPathEntry && ( -
    - No projects yet -
    - )} -
    -
    - ); + const { + sortedProjectKeys, + sidebarProjectByKey, + showArm64IntelBuildWarning, + arm64IntelBuildWarningDescription, + desktopUpdateButtonAction, + desktopUpdateButtonDisabled, + handleDesktopUpdateButtonClick, + projectSortOrder, + threadSortOrder, + updateSettings, + shouldShowProjectPathEntry, + handleStartAddProject, + isElectron, + isPickingFolder, + isAddingProject, + handlePickFolder, + addProjectInputRef, + addProjectError, + newCwd, + setNewCwd, + setAddProjectError, + handleAddProject, + setAddingProject, + canAddProject, + isManualProjectSorting, + projectDnDSensors, + projectCollisionDetection, + handleProjectDragStart, + handleProjectDragEnd, + handleProjectDragCancel, + attachThreadListAutoAnimateRef, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + attachProjectListAutoAnimateRef, + projectsLength, + } = props; + const handleProjectSortOrderChange = useCallback( + (sortOrder: SidebarProjectSortOrder) => { + updateSettings({ sidebarProjectSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadSortOrderChange = useCallback( + (sortOrder: SidebarThreadSortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleAddProjectInputChange = useCallback( + (event: React.ChangeEvent) => { + setNewCwd(event.target.value); + setAddProjectError(null); + }, + [setAddProjectError, setNewCwd], + ); + const handleAddProjectInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") handleAddProject(); + if (event.key === "Escape") { + setAddingProject(false); + setAddProjectError(null); + } + }, + [handleAddProject, setAddProjectError, setAddingProject], + ); + const handleBrowseForFolderClick = useCallback(() => { + void handlePickFolder(); + }, [handlePickFolder]); + + return ( + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( + + + + Intel build on Apple Silicon + {arm64IntelBuildWarningDescription} + {desktopUpdateButtonAction !== "none" ? ( + + + + ) : null} + + + ) : null} + +
    + + Projects + +
    + + + + } + > + + + + {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} + + +
    +
    + {shouldShowProjectPathEntry && ( +
    + {isElectron && ( + + )} +
    + + +
    + {addProjectError && ( +

    + {addProjectError} +

    + )} +
    + )} + + {isManualProjectSorting ? ( + + + + {sortedProjectKeys.map((projectKey) => + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; + } + return ( + + {(dragHandleProps) => ( + + )} + + ); + })(), + )} + + + + ) : ( + + {sortedProjectKeys.map((projectKey) => + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; + } + return ( + + ); + })(), + )} + + )} + + {projectsLength === 0 && !shouldShowProjectPathEntry && ( +
    + No projects yet +
    + )} +
    +
    + ); }); export default function Sidebar() { - const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const reorderProjects = useUiStateStore((store) => store.reorderProjects); - const navigate = useNavigate(); - const isOnSettings = useLocation({ select: (loc) => loc.pathname.startsWith("/settings") }); - const settingsPathname = useLocation({ - select: (loc) => (loc.pathname.startsWith("/settings") ? loc.pathname : "/settings"), - }); - const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); - const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); - const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); - const { updateSettings } = useUpdateSettings(); - const { handleNewThread } = useNewThreadHandler(); - const [addingProject, setAddingProject] = useState(false); - const [newCwd, setNewCwd] = useState(""); - const [isPickingFolder, setIsPickingFolder] = useState(false); - const [isAddingProject, setIsAddingProject] = useState(false); - const [addProjectError, setAddProjectError] = useState(null); - const addProjectInputRef = useRef(null); - const dragInProgressRef = useRef(false); - const suppressProjectClickAfterDragRef = useRef(false); - const suppressProjectClickForContextMenuRef = useRef(false); - const [desktopUpdateState, setDesktopUpdateState] = useState(null); - const clearSelection = useThreadSelectionStore((s) => s.clearSelection); - const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); - const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; - const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); - const orderedProjects = useMemo(() => { - return orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - }); - }, [projectOrder, projects]); - - const physicalToLogicalKey = useMemo( - () => buildSidebarPhysicalToLogicalKeyMap(orderedProjects), - [orderedProjects], - ); - - const previousSidebarProjectSnapshotByKeyRef = useRef< - ReadonlyMap - >(new Map()); - const sidebarProjects = useMemo(() => { - const { projectSnapshotByKey, sidebarProjects: nextSidebarProjects } = - buildSidebarProjectSnapshots({ - orderedProjects, - previousProjectSnapshotByKey: previousSidebarProjectSnapshotByKeyRef.current, - primaryEnvironmentId, - savedEnvironmentRegistryById: savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - }); - previousSidebarProjectSnapshotByKeyRef.current = projectSnapshotByKey; - return [...nextSidebarProjects]; - }, [ - orderedProjects, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); - - const sidebarProjectByKey = useMemo( - () => - new Map( - sidebarProjects.map((project) => [project.projectKey, project] as const), - ), - [sidebarProjects], - ); - const sortedProjectKeys = useStore( - useMemo( - () => - createSidebarSortedProjectKeysSelector({ - physicalToLogicalKey, - projects: sidebarProjects, - sortOrder: sidebarProjectSortOrder, - }), - [physicalToLogicalKey, sidebarProjectSortOrder, sidebarProjects], - ), - ); - const focusMostRecentThreadForProject = useCallback( - (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { - const physicalKey = scopedProjectKey( - scopeProjectRef(projectRef.environmentId, projectRef.projectId), - ); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const memberProjectRefs = sidebarProjectByKey.get(logicalKey)?.memberProjectRefs ?? [ - projectRef, - ]; - const latestThread = sortThreadsForSidebar( - selectSidebarThreadsForProjectRefs(useStore.getState(), memberProjectRefs).filter( - (thread) => thread.archivedAt === null, - ), - sidebarThreadSortOrder, - )[0]; - if (!latestThread) return; - - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), - }); - }, - [navigate, physicalToLogicalKey, sidebarProjectByKey, sidebarThreadSortOrder], - ); - - const addProjectFromPath = useCallback( - async (rawCwd: string) => { - const cwd = rawCwd.trim(); - if (!cwd || isAddingProject) return; - const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; - if (!api) return; - - setIsAddingProject(true); - const finishAddingProject = () => { - setIsAddingProject(false); - setNewCwd(""); - setAddProjectError(null); - setAddingProject(false); - }; - - const existing = projects.find((project) => project.cwd === cwd); - if (existing) { - focusMostRecentThreadForProject({ - environmentId: existing.environmentId, - projectId: existing.id, - }); - finishAddingProject(); - return; - } - - const projectId = newProjectId(); - const createdAt = new Date().toISOString(); - const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; - try { - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: newCommandId(), - projectId, - title, - workspaceRoot: cwd, - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - createdAt, - }); - if (activeEnvironmentId !== null) { - await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { - envMode: defaultThreadEnvMode, - }).catch(() => undefined); - } - } catch (error) { - const description = - error instanceof Error ? error.message : "An error occurred while adding the project."; - setIsAddingProject(false); - if (shouldBrowseForProjectImmediately) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description, - }); - } else { - setAddProjectError(description); - } - return; - } - finishAddingProject(); - }, - [ - focusMostRecentThreadForProject, - activeEnvironmentId, - handleNewThread, - isAddingProject, - projects, - shouldBrowseForProjectImmediately, - defaultThreadEnvMode, - ], - ); - - const handleAddProject = () => { - void addProjectFromPath(newCwd); - }; - - const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - - const handlePickFolder = async () => { - const api = readLocalApi(); - if (!api || isPickingFolder) return; - setIsPickingFolder(true); - let pickedPath: string | null = null; - try { - pickedPath = await api.dialogs.pickFolder(); - } catch { - // Ignore picker failures and leave the current thread selection unchanged. - } - if (pickedPath) { - await addProjectFromPath(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { - addProjectInputRef.current?.focus(); - } - setIsPickingFolder(false); - }; - - const handleStartAddProject = () => { - setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { - void handlePickFolder(); - return; - } - setAddingProject((prev) => !prev); - }; - - const navigateToThread = useCallback( - (threadRef: ScopedThreadRef) => { - if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { - clearSelection(); - } - setSelectionAnchor(scopedThreadKey(threadRef)); - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }, - [clearSelection, navigate, setSelectionAnchor], - ); - - const projectDnDSensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 6 }, - }), - ); - const projectCollisionDetection = useCallback((args) => { - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) { - return pointerCollisions; - } - - return closestCorners(args); - }, []); - - const handleProjectDragEnd = useCallback( - (event: DragEndEvent) => { - if (sidebarProjectSortOrder !== "manual") { - dragInProgressRef.current = false; - return; - } - dragInProgressRef.current = false; - const { active, over } = event; - if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); - const overProject = sidebarProjects.find((project) => project.projectKey === over.id); - if (!activeProject || !overProject) return; - reorderProjects(activeProject.projectKey, overProject.projectKey); - }, - [sidebarProjectSortOrder, reorderProjects, sidebarProjects], - ); - - const handleProjectDragStart = useCallback( - (_event: DragStartEvent) => { - if (sidebarProjectSortOrder !== "manual") { - return; - } - dragInProgressRef.current = true; - suppressProjectClickAfterDragRef.current = true; - }, - [sidebarProjectSortOrder], - ); - - const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { - dragInProgressRef.current = false; - }, []); - - const animatedProjectListsRef = useRef(new WeakSet()); - const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { - if (!node || animatedProjectListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedProjectListsRef.current.add(node); - }, []); - - const animatedThreadListsRef = useRef(new WeakSet()); - const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { - if (!node || animatedThreadListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedThreadListsRef.current.add(node); - }, []); - - const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - - useEffect(() => { - return () => { - resetSidebarViewState(); - }; - }, []); - - useEffect(() => { - if (!isElectron) return; - const bridge = window.desktopBridge; - if ( - !bridge || - typeof bridge.getUpdateState !== "function" || - typeof bridge.onUpdateState !== "function" - ) { - return; - } - - let disposed = false; - let receivedSubscriptionUpdate = false; - const unsubscribe = bridge.onUpdateState((nextState) => { - if (disposed) return; - receivedSubscriptionUpdate = true; - setDesktopUpdateState(nextState); - }); - - void bridge - .getUpdateState() - .then((nextState) => { - if (disposed || receivedSubscriptionUpdate) return; - setDesktopUpdateState(nextState); - }) - .catch(() => undefined); - - return () => { - disposed = true; - unsubscribe(); - }; - }, []); - - const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); - const desktopUpdateButtonAction = desktopUpdateState - ? resolveDesktopUpdateButtonAction(desktopUpdateState) - : "none"; - const showArm64IntelBuildWarning = - isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); - const arm64IntelBuildWarningDescription = - desktopUpdateState && showArm64IntelBuildWarning - ? getArm64IntelBuildWarningDescription(desktopUpdateState) - : null; - const handleDesktopUpdateButtonClick = useCallback(() => { - const bridge = window.desktopBridge; - if (!bridge || !desktopUpdateState) return; - if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; - - if (desktopUpdateButtonAction === "download") { - void bridge - .downloadUpdate() - .then((result) => { - if (result.completed) { - toastManager.add({ - type: "success", - title: "Update downloaded", - description: "Restart the app from the update button to install it.", - }); - } - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not download update", - description: actionError, - }); - }) - .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); - }); - return; - } - - if (desktopUpdateButtonAction === "install") { - const confirmed = window.confirm( - getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), - ); - if (!confirmed) return; - void bridge - .installUpdate() - .then((result) => { - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not install update", - description: actionError, - }); - }) - .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); - }); - } - }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - - return ( - <> - - - - - {isOnSettings ? ( - - ) : ( - <> - - - - - - )} - - ); + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const navigate = useNavigate(); + const isOnSettings = useLocation({ select: (loc) => loc.pathname.startsWith("/settings") }); + const settingsPathname = useLocation({ + select: (loc) => (loc.pathname.startsWith("/settings") ? loc.pathname : "/settings"), + }); + const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); + const { updateSettings } = useUpdateSettings(); + const { handleNewThread } = useNewThreadHandler(); + const [addingProject, setAddingProject] = useState(false); + const [newCwd, setNewCwd] = useState(""); + const [isPickingFolder, setIsPickingFolder] = useState(false); + const [isAddingProject, setIsAddingProject] = useState(false); + const [addProjectError, setAddProjectError] = useState(null); + const addProjectInputRef = useRef(null); + const dragInProgressRef = useRef(false); + const suppressProjectClickAfterDragRef = useRef(false); + const suppressProjectClickForContextMenuRef = useRef(false); + const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); + const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; + const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + }); + }, [projectOrder, projects]); + + const physicalToLogicalKey = useMemo( + () => buildSidebarPhysicalToLogicalKeyMap(orderedProjects), + [orderedProjects], + ); + + const previousSidebarProjectSnapshotByKeyRef = useRef< + ReadonlyMap + >(new Map()); + const sidebarProjects = useMemo(() => { + const { projectSnapshotByKey, sidebarProjects: nextSidebarProjects } = + buildSidebarProjectSnapshots({ + orderedProjects, + previousProjectSnapshotByKey: previousSidebarProjectSnapshotByKeyRef.current, + primaryEnvironmentId, + savedEnvironmentRegistryById: savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + }); + previousSidebarProjectSnapshotByKeyRef.current = projectSnapshotByKey; + return [...nextSidebarProjects]; + }, [ + orderedProjects, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + + const sidebarProjectByKey = useMemo( + () => + new Map( + sidebarProjects.map((project) => [project.projectKey, project] as const), + ), + [sidebarProjects], + ); + const sortedProjectKeys = useStore( + useMemo( + () => + createSidebarSortedProjectKeysSelector({ + physicalToLogicalKey, + projects: sidebarProjects, + sortOrder: sidebarProjectSortOrder, + }), + [physicalToLogicalKey, sidebarProjectSortOrder, sidebarProjects], + ), + ); + const focusMostRecentThreadForProject = useCallback( + (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { + const physicalKey = scopedProjectKey( + scopeProjectRef(projectRef.environmentId, projectRef.projectId), + ); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const memberProjectRefs = sidebarProjectByKey.get(logicalKey)?.memberProjectRefs ?? [ + projectRef, + ]; + const latestThread = sortThreadsForSidebar( + selectSidebarThreadsForProjectRefs(useStore.getState(), memberProjectRefs).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + )[0]; + if (!latestThread) return; + + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), + }); + }, + [navigate, physicalToLogicalKey, sidebarProjectByKey, sidebarThreadSortOrder], + ); + + const addProjectFromPath = useCallback( + async (rawCwd: string) => { + const cwd = rawCwd.trim(); + if (!cwd || isAddingProject) return; + const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; + if (!api) return; + + setIsAddingProject(true); + const finishAddingProject = () => { + setIsAddingProject(false); + setNewCwd(""); + setAddProjectError(null); + setAddingProject(false); + }; + + const existing = projects.find((project) => project.cwd === cwd); + if (existing) { + focusMostRecentThreadForProject({ + environmentId: existing.environmentId, + projectId: existing.id, + }); + finishAddingProject(); + return; + } + + const projectId = newProjectId(); + const createdAt = new Date().toISOString(); + const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; + try { + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title, + workspaceRoot: cwd, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + createdAt, + }); + if (activeEnvironmentId !== null) { + await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { + envMode: defaultThreadEnvMode, + }).catch(() => undefined); + } + } catch (error) { + const description = + error instanceof Error ? error.message : "An error occurred while adding the project."; + setIsAddingProject(false); + if (shouldBrowseForProjectImmediately) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description, + }); + } else { + setAddProjectError(description); + } + return; + } + finishAddingProject(); + }, + [ + focusMostRecentThreadForProject, + activeEnvironmentId, + handleNewThread, + isAddingProject, + projects, + shouldBrowseForProjectImmediately, + defaultThreadEnvMode, + ], + ); + + const handleAddProject = () => { + void addProjectFromPath(newCwd); + }; + + const canAddProject = newCwd.trim().length > 0 && !isAddingProject; + + const handlePickFolder = async () => { + const api = readLocalApi(); + if (!api || isPickingFolder) return; + setIsPickingFolder(true); + let pickedPath: string | null = null; + try { + pickedPath = await api.dialogs.pickFolder(); + } catch { + // Ignore picker failures and leave the current thread selection unchanged. + } + if (pickedPath) { + await addProjectFromPath(pickedPath); + } else if (!shouldBrowseForProjectImmediately) { + addProjectInputRef.current?.focus(); + } + setIsPickingFolder(false); + }; + + const handleStartAddProject = () => { + setAddProjectError(null); + if (shouldBrowseForProjectImmediately) { + void handlePickFolder(); + return; + } + setAddingProject((prev) => !prev); + }; + + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, navigate, setSelectionAnchor], + ); + + const projectDnDSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + ); + const projectCollisionDetection = useCallback((args) => { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + + return closestCorners(args); + }, []); + + const handleProjectDragEnd = useCallback( + (event: DragEndEvent) => { + if (sidebarProjectSortOrder !== "manual") { + dragInProgressRef.current = false; + return; + } + dragInProgressRef.current = false; + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); + const overProject = sidebarProjects.find((project) => project.projectKey === over.id); + if (!activeProject || !overProject) return; + reorderProjects(activeProject.projectKey, overProject.projectKey); + }, + [sidebarProjectSortOrder, reorderProjects, sidebarProjects], + ); + + const handleProjectDragStart = useCallback( + (_event: DragStartEvent) => { + if (sidebarProjectSortOrder !== "manual") { + return; + } + dragInProgressRef.current = true; + suppressProjectClickAfterDragRef.current = true; + }, + [sidebarProjectSortOrder], + ); + + const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { + dragInProgressRef.current = false; + }, []); + + const animatedProjectListsRef = useRef(new WeakSet()); + const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedProjectListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedProjectListsRef.current.add(node); + }, []); + + const animatedThreadListsRef = useRef(new WeakSet()); + const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedThreadListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedThreadListsRef.current.add(node); + }, []); + + const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + + useEffect(() => { + return () => { + resetSidebarViewState(); + }; + }, []); + + useEffect(() => { + if (!isElectron) return; + const bridge = window.desktopBridge; + if ( + !bridge || + typeof bridge.getUpdateState !== "function" || + typeof bridge.onUpdateState !== "function" + ) { + return; + } + + let disposed = false; + let receivedSubscriptionUpdate = false; + const unsubscribe = bridge.onUpdateState((nextState) => { + if (disposed) return; + receivedSubscriptionUpdate = true; + setDesktopUpdateState(nextState); + }); + + void bridge + .getUpdateState() + .then((nextState) => { + if (disposed || receivedSubscriptionUpdate) return; + setDesktopUpdateState(nextState); + }) + .catch(() => undefined); + + return () => { + disposed = true; + unsubscribe(); + }; + }, []); + + const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); + const desktopUpdateButtonAction = desktopUpdateState + ? resolveDesktopUpdateButtonAction(desktopUpdateState) + : "none"; + const showArm64IntelBuildWarning = + isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); + const arm64IntelBuildWarningDescription = + desktopUpdateState && showArm64IntelBuildWarning + ? getArm64IntelBuildWarningDescription(desktopUpdateState) + : null; + const handleDesktopUpdateButtonClick = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge || !desktopUpdateState) return; + if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; + + if (desktopUpdateButtonAction === "download") { + void bridge + .downloadUpdate() + .then((result) => { + if (result.completed) { + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not download update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + return; + } + + if (desktopUpdateButtonAction === "install") { + const confirmed = window.confirm( + getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), + ); + if (!confirmed) return; + void bridge + .installUpdate() + .then((result) => { + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not install update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + } + }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); + + return ( + <> + + + + + {isOnSettings ? ( + + ) : ( + <> + + + + + + )} + + ); } diff --git a/apps/web/src/components/sidebar/sidebarControllers.tsx b/apps/web/src/components/sidebar/sidebarControllers.tsx index 9d83b9f94b..c17bb62504 100644 --- a/apps/web/src/components/sidebar/sidebarControllers.tsx +++ b/apps/web/src/components/sidebar/sidebarControllers.tsx @@ -7,28 +7,28 @@ import { useShallow } from "zustand/react/shallow"; import { isTerminalFocused } from "../../lib/terminalFocus"; import { resolveThreadRouteRef } from "../../threadRoutes"; import { - resolveShortcutCommand, - shortcutLabelForCommand, - shouldShowThreadJumpHints, - threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHints, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, } from "../../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../../terminalStateStore"; import { useUiStateStore } from "../../uiStateStore"; import { - resolveAdjacentThreadId, - shouldClearThreadSelectionOnMouseDown, - useThreadJumpHintVisibility, + resolveAdjacentThreadId, + shouldClearThreadSelectionOnMouseDown, + useThreadJumpHintVisibility, } from "../Sidebar.logic"; import { - createSidebarActiveRouteProjectKeySelectorByRef, - createSidebarSortedThreadKeysByLogicalProjectSelector, + createSidebarActiveRouteProjectKeySelectorByRef, + createSidebarSortedThreadKeysByLogicalProjectSelector, } from "./sidebarSelectors"; import type { LogicalProjectKey } from "../../logicalProject"; import { - setSidebarKeyboardState, - useSidebarExpandedThreadListsByProject, + setSidebarKeyboardState, + useSidebarExpandedThreadListsByProject, } from "./sidebarViewStore"; import { useServerKeybindings } from "../../rpc/serverState"; import { useStore } from "../../store"; @@ -38,386 +38,386 @@ import { THREAD_PREVIEW_LIMIT } from "./sidebarConstants"; const EMPTY_THREAD_JUMP_LABELS = new Map(); function buildThreadJumpLabelMap(input: { - keybindings: ReturnType; - platform: string; - terminalOpen: boolean; - threadJumpCommandByKey: ReadonlyMap< - string, - NonNullable> - >; + keybindings: ReturnType; + platform: string; + terminalOpen: boolean; + threadJumpCommandByKey: ReadonlyMap< + string, + NonNullable> + >; }): ReadonlyMap { - if (input.threadJumpCommandByKey.size === 0) { - return EMPTY_THREAD_JUMP_LABELS; - } + if (input.threadJumpCommandByKey.size === 0) { + return EMPTY_THREAD_JUMP_LABELS; + } - const shortcutLabelOptions = { - platform: input.platform, - context: { - terminalFocus: false, - terminalOpen: input.terminalOpen, - }, - } as const; - const mapping = new Map(); - for (const [threadKey, command] of input.threadJumpCommandByKey) { - const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); - if (label) { - mapping.set(threadKey, label); - } - } - return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; + const shortcutLabelOptions = { + platform: input.platform, + context: { + terminalFocus: false, + terminalOpen: input.terminalOpen, + }, + } as const; + const mapping = new Map(); + for (const [threadKey, command] of input.threadJumpCommandByKey) { + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(threadKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } function useSidebarKeyboardController(input: { - physicalToLogicalKey: ReadonlyMap; - sortedProjectKeys: readonly LogicalProjectKey[]; - expandedThreadListsByProject: ReadonlySet; - routeThreadRef: ScopedThreadRef | null; - routeThreadKey: string | null; - platform: string; - keybindings: ReturnType; - navigateToThread: (threadRef: ScopedThreadRef) => void; - threadSortOrder: SidebarThreadSortOrder; + physicalToLogicalKey: ReadonlyMap; + sortedProjectKeys: readonly LogicalProjectKey[]; + expandedThreadListsByProject: ReadonlySet; + routeThreadRef: ScopedThreadRef | null; + routeThreadKey: string | null; + platform: string; + keybindings: ReturnType; + navigateToThread: (threadRef: ScopedThreadRef) => void; + threadSortOrder: SidebarThreadSortOrder; }) { - const { - physicalToLogicalKey, - sortedProjectKeys, - expandedThreadListsByProject, - routeThreadRef, - routeThreadKey, - platform, - keybindings, - navigateToThread, - threadSortOrder, - } = input; - const projectExpandedStates = useUiStateStore( - useShallow((store) => - sortedProjectKeys.map((projectKey) => store.projectExpandedById[projectKey] ?? true), - ), - ); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const sortedThreadKeysByLogicalProject = useStore( - useMemo( - () => - createSidebarSortedThreadKeysByLogicalProjectSelector({ - physicalToLogicalKey, - threadSortOrder, - }), - [physicalToLogicalKey, threadSortOrder], - ), - ); - const visibleSidebarThreadKeys = useMemo( - () => - sortedProjectKeys.flatMap((projectKey, index) => { - const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; - const projectExpanded = projectExpandedStates[index] ?? true; - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } + const { + physicalToLogicalKey, + sortedProjectKeys, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + threadSortOrder, + } = input; + const projectExpandedStates = useUiStateStore( + useShallow((store) => + sortedProjectKeys.map((projectKey) => store.projectExpandedById[projectKey] ?? true), + ), + ); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const sortedThreadKeysByLogicalProject = useStore( + useMemo( + () => + createSidebarSortedThreadKeysByLogicalProjectSelector({ + physicalToLogicalKey, + threadSortOrder, + }), + [physicalToLogicalKey, threadSortOrder], + ), + ); + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjectKeys.flatMap((projectKey, index) => { + const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; + const projectExpanded = projectExpandedStates[index] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } - const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); - const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; - const previewThreadKeys = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreadKeys - : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); - return pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreadKeys; - }), - [ - expandedThreadListsByProject, - projectExpandedStates, - routeThreadKey, - sortedProjectKeys, - sortedThreadKeysByLogicalProject, - ], - ); - const threadJumpCommandByKey = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - return mapping; - } - mapping.set(threadKey, jumpCommand); - } + const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); + const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; + const previewThreadKeys = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreadKeys + : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); + return pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreadKeys; + }), + [ + expandedThreadListsByProject, + projectExpandedStates, + routeThreadKey, + sortedProjectKeys, + sortedThreadKeysByLogicalProject, + ], + ); + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(threadKey, jumpCommand); + } - return mapping; - }, [visibleSidebarThreadKeys]); - const threadJumpThreadKeys = useMemo( - () => [...threadJumpCommandByKey.keys()], - [threadJumpCommandByKey], - ); - const getCurrentSidebarShortcutContext = useCallback( - () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - }), - [routeThreadRef], - ); - const threadJumpLabelByKey = useMemo( - () => - showThreadJumpHints - ? buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: getCurrentSidebarShortcutContext().terminalOpen, - threadJumpCommandByKey, - }) - : EMPTY_THREAD_JUMP_LABELS, - [ - getCurrentSidebarShortcutContext, - keybindings, - platform, - showThreadJumpHints, - threadJumpCommandByKey, - ], - ); - const threadJumpLabelsRef = useRef>(threadJumpLabelByKey); - threadJumpLabelsRef.current = threadJumpLabelByKey; - const showThreadJumpHintsRef = useRef(showThreadJumpHints); - showThreadJumpHintsRef.current = showThreadJumpHints; - const latestKeyboardStateRef = useRef({ - keybindings, - navigateToThread, - platform, - routeThreadKey, - routeThreadRef, - threadJumpThreadKeys, - visibleSidebarThreadKeys, - }); - latestKeyboardStateRef.current = { - keybindings, - navigateToThread, - platform, - routeThreadKey, - routeThreadRef, - threadJumpThreadKeys, - visibleSidebarThreadKeys, - }; - const updateThreadJumpHintsVisibilityRef = useRef(updateThreadJumpHintsVisibility); - updateThreadJumpHintsVisibilityRef.current = updateThreadJumpHintsVisibility; + return mapping; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }), + [routeThreadRef], + ); + const threadJumpLabelByKey = useMemo( + () => + showThreadJumpHints + ? buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: getCurrentSidebarShortcutContext().terminalOpen, + threadJumpCommandByKey, + }) + : EMPTY_THREAD_JUMP_LABELS, + [ + getCurrentSidebarShortcutContext, + keybindings, + platform, + showThreadJumpHints, + threadJumpCommandByKey, + ], + ); + const threadJumpLabelsRef = useRef>(threadJumpLabelByKey); + threadJumpLabelsRef.current = threadJumpLabelByKey; + const showThreadJumpHintsRef = useRef(showThreadJumpHints); + showThreadJumpHintsRef.current = showThreadJumpHints; + const latestKeyboardStateRef = useRef({ + keybindings, + navigateToThread, + platform, + routeThreadKey, + routeThreadRef, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + }); + latestKeyboardStateRef.current = { + keybindings, + navigateToThread, + platform, + routeThreadKey, + routeThreadRef, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + }; + const updateThreadJumpHintsVisibilityRef = useRef(updateThreadJumpHintsVisibility); + updateThreadJumpHintsVisibilityRef.current = updateThreadJumpHintsVisibility; - useEffect(() => { - const clearThreadJumpHints = () => { - updateThreadJumpHintsVisibilityRef.current(false); - }; - const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - event.key !== "Meta" && - event.key !== "Control" && - event.key !== "Alt" && - event.key !== "Shift" && - !showThreadJumpHintsRef.current && - threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; - const getCurrentSidebarShortcutContext = () => { - const { routeThreadRef } = latestKeyboardStateRef.current; - return { - terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - }; - }; + useEffect(() => { + const clearThreadJumpHints = () => { + updateThreadJumpHintsVisibilityRef.current(false); + }; + const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + event.key !== "Meta" && + event.key !== "Control" && + event.key !== "Alt" && + event.key !== "Shift" && + !showThreadJumpHintsRef.current && + threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + const getCurrentSidebarShortcutContext = () => { + const { routeThreadRef } = latestKeyboardStateRef.current; + return { + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }; + }; - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } - const { - keybindings, - navigateToThread, - platform, - routeThreadKey, - threadJumpThreadKeys, - visibleSidebarThreadKeys, - } = latestKeyboardStateRef.current; - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - if ( - showThreadJumpHintsRef.current || - threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS - ) { - clearThreadJumpHints(); - } - } else { - updateThreadJumpHintsVisibilityRef.current(true); - } + const { + keybindings, + navigateToThread, + platform, + routeThreadKey, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + } = latestKeyboardStateRef.current; + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + if ( + showThreadJumpHintsRef.current || + threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS + ) { + clearThreadJumpHints(); + } + } else { + updateThreadJumpHintsVisibilityRef.current(true); + } - if (event.defaultPrevented || event.repeat) { - return; - } + if (event.defaultPrevented || event.repeat) { + return; + } - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, - }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: visibleSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = parseScopedThreadKey(targetThreadKey); - if (!targetThread) { - return; - } + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: shortcutContext, + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: visibleSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }); + if (!targetThreadKey) { + return; + } + const targetThread = parseScopedThreadKey(targetThreadKey); + if (!targetThread) { + return; + } - event.preventDefault(); - event.stopPropagation(); - navigateToThread(targetThread); - return; - } + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThread); + return; + } - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = parseScopedThreadKey(targetThreadKey); - if (!targetThread) { - return; - } + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = parseScopedThreadKey(targetThreadKey); + if (!targetThread) { + return; + } - event.preventDefault(); - event.stopPropagation(); - navigateToThread(targetThread); - }; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThread); + }; - const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } + const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } - const { keybindings, platform } = latestKeyboardStateRef.current; - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - clearThreadJumpHints(); - return; - } - updateThreadJumpHintsVisibilityRef.current(true); - }; + const { keybindings, platform } = latestKeyboardStateRef.current; + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + clearThreadJumpHints(); + return; + } + updateThreadJumpHintsVisibilityRef.current(true); + }; - const onWindowBlur = () => { - clearThreadJumpHints(); - }; + const onWindowBlur = () => { + clearThreadJumpHints(); + }; - window.addEventListener("keydown", onWindowKeyDown); - window.addEventListener("keyup", onWindowKeyUp); - window.addEventListener("blur", onWindowBlur); + window.addEventListener("keydown", onWindowKeyDown); + window.addEventListener("keyup", onWindowKeyUp); + window.addEventListener("blur", onWindowBlur); - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); - }; - }, []); + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + window.removeEventListener("keyup", onWindowKeyUp); + window.removeEventListener("blur", onWindowBlur); + }; + }, []); - return threadJumpLabelByKey; + return threadJumpLabelByKey; } export const SidebarSelectionController = memo(function SidebarSelectionController() { - const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); - const clearSelection = useThreadSelectionStore((state) => state.clearSelection); - const selectedThreadCountRef = useRef(selectedThreadCount); - selectedThreadCountRef.current = selectedThreadCount; - const clearSelectionRef = useRef(clearSelection); - clearSelectionRef.current = clearSelection; + const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const selectedThreadCountRef = useRef(selectedThreadCount); + selectedThreadCountRef.current = selectedThreadCount; + const clearSelectionRef = useRef(clearSelection); + clearSelectionRef.current = clearSelection; - useEffect(() => { - const onMouseDown = (event: globalThis.MouseEvent) => { - if (selectedThreadCountRef.current === 0) { - return; - } - const target = event.target instanceof HTMLElement ? event.target : null; - if (!shouldClearThreadSelectionOnMouseDown(target)) { - return; - } - clearSelectionRef.current(); - }; + useEffect(() => { + const onMouseDown = (event: globalThis.MouseEvent) => { + if (selectedThreadCountRef.current === 0) { + return; + } + const target = event.target instanceof Element ? event.target : null; + if (!shouldClearThreadSelectionOnMouseDown(target)) { + return; + } + clearSelectionRef.current(); + }; - window.addEventListener("mousedown", onMouseDown); - return () => { - window.removeEventListener("mousedown", onMouseDown); - }; - }, []); + window.addEventListener("mousedown", onMouseDown); + return () => { + window.removeEventListener("mousedown", onMouseDown); + }; + }, []); - return null; + return null; }); export const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { - navigateToThread: (threadRef: ScopedThreadRef) => void; - physicalToLogicalKey: ReadonlyMap; - sortedProjectKeys: readonly LogicalProjectKey[]; - sidebarThreadSortOrder: SidebarThreadSortOrder; + navigateToThread: (threadRef: ScopedThreadRef) => void; + physicalToLogicalKey: ReadonlyMap; + sortedProjectKeys: readonly LogicalProjectKey[]; + sidebarThreadSortOrder: SidebarThreadSortOrder; }) { - const { navigateToThread, physicalToLogicalKey, sortedProjectKeys, sidebarThreadSortOrder } = - props; - const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); - const routeThreadRef = useParams({ - strict: false, - select: (params) => resolveThreadRouteRef(params), - }); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const activeRouteProjectKey = useStore( - useMemo( - () => createSidebarActiveRouteProjectKeySelectorByRef(routeThreadRef, physicalToLogicalKey), - [physicalToLogicalKey, routeThreadRef], - ), - ); - const keybindings = useServerKeybindings(); - const platform = navigator.platform; - const threadJumpLabelByKey = useSidebarKeyboardController({ - physicalToLogicalKey, - sortedProjectKeys, - expandedThreadListsByProject, - routeThreadRef, - routeThreadKey, - platform, - keybindings, - navigateToThread, - threadSortOrder: sidebarThreadSortOrder, - }); + const { navigateToThread, physicalToLogicalKey, sortedProjectKeys, sidebarThreadSortOrder } = + props; + const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const activeRouteProjectKey = useStore( + useMemo( + () => createSidebarActiveRouteProjectKeySelectorByRef(routeThreadRef, physicalToLogicalKey), + [physicalToLogicalKey, routeThreadRef], + ), + ); + const keybindings = useServerKeybindings(); + const platform = navigator.platform; + const threadJumpLabelByKey = useSidebarKeyboardController({ + physicalToLogicalKey, + sortedProjectKeys, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + threadSortOrder: sidebarThreadSortOrder, + }); - useEffect(() => { - setSidebarKeyboardState({ - activeRouteProjectKey, - activeRouteThreadKey: routeThreadKey, - threadJumpLabelByKey, - }); - }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); + useEffect(() => { + setSidebarKeyboardState({ + activeRouteProjectKey, + activeRouteThreadKey: routeThreadKey, + threadJumpLabelByKey, + }); + }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); - return null; + return null; }); From aebb3d802abda0540834af6755a09cefa5659757 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:34:43 +0100 Subject: [PATCH 8/9] fix fmt issues --- apps/web/src/components/Sidebar.tsx | 5025 +++++++++-------- .../components/sidebar/sidebarControllers.tsx | 720 +-- 2 files changed, 2877 insertions(+), 2868 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 874f7b83c1..b62ff97508 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,69 +1,69 @@ import { - ArchiveIcon, - ArrowUpDownIcon, - ChevronRightIcon, - CloudIcon, - FolderIcon, - GitPullRequestIcon, - PlusIcon, - SettingsIcon, - SquarePenIcon, - TerminalIcon, - TriangleAlertIcon, + ArchiveIcon, + ArrowUpDownIcon, + ChevronRightIcon, + CloudIcon, + FolderIcon, + GitPullRequestIcon, + PlusIcon, + SettingsIcon, + SquarePenIcon, + TerminalIcon, + TriangleAlertIcon, } from "lucide-react"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { - DndContext, - type DragCancelEvent, - type CollisionDetection, - PointerSensor, - type DragStartEvent, - closestCorners, - pointerWithin, - useSensor, - useSensors, - type DragEndEvent, + DndContext, + type DragCancelEvent, + type CollisionDetection, + PointerSensor, + type DragStartEvent, + closestCorners, + pointerWithin, + useSensor, + useSensors, + type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - DEFAULT_MODEL_BY_PROVIDER, - type DesktopUpdateState, - type EnvironmentId, - ProjectId, - type ScopedThreadRef, - type ThreadEnvMode, - ThreadId, - type GitStatusResult, + DEFAULT_MODEL_BY_PROVIDER, + type DesktopUpdateState, + type EnvironmentId, + ProjectId, + type ScopedThreadRef, + type ThreadEnvMode, + ThreadId, + type GitStatusResult, } from "@t3tools/contracts"; import { - parseScopedThreadKey, - scopedProjectKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, + parseScopedThreadKey, + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, } from "@t3tools/client-runtime"; import { Link, useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { - type SidebarProjectSortOrder, - type SidebarThreadSortOrder, + type SidebarProjectSortOrder, + type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { - selectProjectByRef, - selectProjectsAcrossEnvironments, - selectSidebarThreadSummaryByRef, - selectSidebarThreadsForProjectRefs, - selectThreadIdsByProjectRef, - selectThreadByRef, - useStore, + selectProjectByRef, + selectProjectsAcrossEnvironments, + selectSidebarThreadSummaryByRef, + selectSidebarThreadsForProjectRefs, + selectThreadIdsByProjectRef, + selectThreadByRef, + useStore, } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -79,2639 +79,2648 @@ import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { - getArm64IntelBuildWarningDescription, - getDesktopUpdateActionError, - getDesktopUpdateInstallConfirmationMessage, - isDesktopUpdateButtonDisabled, - resolveDesktopUpdateButtonAction, - shouldShowArm64IntelBuildWarning, - shouldToastDesktopUpdateActionResult, + getArm64IntelBuildWarningDescription, + getDesktopUpdateActionError, + getDesktopUpdateInstallConfirmationMessage, + isDesktopUpdateButtonDisabled, + resolveDesktopUpdateButtonAction, + shouldShowArm64IntelBuildWarning, + shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarSeparator, - SidebarTrigger, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarSeparator, + SidebarTrigger, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { - isContextMenuPointerDown, - resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, - resolveSidebarNewThreadEnvMode, - resolveThreadRowClassName, - resolveThreadStatusPill, - orderItemsByPreferredIds, - sortThreadsForSidebar, - ThreadStatusPill, + isContextMenuPointerDown, + resolveProjectStatusIndicator, + resolveSidebarNewThreadSeedContext, + resolveSidebarNewThreadEnvMode, + resolveThreadRowClassName, + resolveThreadStatusPill, + orderItemsByPreferredIds, + sortThreadsForSidebar, + ThreadStatusPill, } from "./Sidebar.logic"; import { - createSidebarSortedProjectKeysSelector, - createSidebarProjectRenderStateSelector, - createSidebarProjectThreadStatusInputsSelector, - createSidebarThreadMetaSnapshotSelectorByRef, - createSidebarThreadRowSnapshotSelectorByRef, - createSidebarThreadStatusInputSelectorByRef, - type ProjectThreadStatusInput, + createSidebarSortedProjectKeysSelector, + createSidebarProjectRenderStateSelector, + createSidebarProjectThreadStatusInputsSelector, + createSidebarThreadMetaSnapshotSelectorByRef, + createSidebarThreadRowSnapshotSelectorByRef, + createSidebarThreadStatusInputSelectorByRef, + type ProjectThreadStatusInput, } from "./sidebar/sidebarSelectors"; import { THREAD_PREVIEW_LIMIT } from "./sidebar/sidebarConstants"; import { - SidebarKeyboardController, - SidebarSelectionController, + SidebarKeyboardController, + SidebarSelectionController, } from "./sidebar/sidebarControllers"; import { - collapseSidebarProjectThreadList, - expandSidebarProjectThreadList, - resetSidebarViewState, - useSidebarIsActiveThread, - useSidebarProjectActiveRouteThreadKey, - useSidebarProjectThreadListExpanded, - useSidebarThreadJumpLabel, + collapseSidebarProjectThreadList, + expandSidebarProjectThreadList, + resetSidebarViewState, + useSidebarIsActiveThread, + useSidebarProjectActiveRouteThreadKey, + useSidebarProjectThreadListExpanded, + useSidebarThreadJumpLabel, } from "./sidebar/sidebarViewStore"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { - buildSidebarPhysicalToLogicalKeyMap, - buildSidebarProjectSnapshots, - type SidebarProjectSnapshot, + buildSidebarPhysicalToLogicalKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectSnapshot, } from "./sidebar/sidebarProjectSnapshots"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import type { LogicalProjectKey } from "../logicalProject"; const SIDEBAR_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", - manual: "Manual", + updated_at: "Last user message", + created_at: "Created at", + manual: "Manual", }; const SIDEBAR_THREAD_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", + updated_at: "Last user message", + created_at: "Created at", }; const SIDEBAR_LIST_ANIMATION_OPTIONS = { - duration: 180, - easing: "ease-out", + duration: 180, + easing: "ease-out", } as const; interface TerminalStatusIndicator { - label: "Terminal process running"; - colorClass: string; - pulse: boolean; + label: "Terminal process running"; + colorClass: string; + pulse: boolean; } interface PrStatusIndicator { - label: "PR open" | "PR closed" | "PR merged"; - colorClass: string; - tooltip: string; - url: string; + label: "PR open" | "PR closed" | "PR merged"; + colorClass: string; + tooltip: string; + url: string; } type ThreadPr = GitStatusResult["pr"]; function useSidebarThreadStatusInput( - threadRef: ScopedThreadRef | null, + threadRef: ScopedThreadRef | null, ): ProjectThreadStatusInput | undefined { - return useStore( - useMemo(() => createSidebarThreadStatusInputSelectorByRef(threadRef), [threadRef]), - ); + return useStore( + useMemo(() => createSidebarThreadStatusInputSelectorByRef(threadRef), [threadRef]), + ); } function ThreadStatusLabel({ - status, - compact = false, + status, + compact = false, }: { - status: ThreadStatusPill; - compact?: boolean; + status: ThreadStatusPill; + compact?: boolean; }) { - if (compact) { - return ( - - - {status.label} - - ); - } - - return ( - - - {status.label} - - ); + if (compact) { + return ( + + + {status.label} + + ); + } + + return ( + + + {status.label} + + ); } function terminalStatusFromRunningIds( - runningTerminalIds: string[], + runningTerminalIds: string[], ): TerminalStatusIndicator | null { - if (runningTerminalIds.length === 0) { - return null; - } - return { - label: "Terminal process running", - colorClass: "text-teal-600 dark:text-teal-300/90", - pulse: true, - }; + if (runningTerminalIds.length === 0) { + return null; + } + return { + label: "Terminal process running", + colorClass: "text-teal-600 dark:text-teal-300/90", + pulse: true, + }; } function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { - if (!pr) return null; - - if (pr.state === "open") { - return { - label: "PR open", - colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} PR open: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "closed") { - return { - label: "PR closed", - colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} PR closed: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "merged") { - return { - label: "PR merged", - colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} PR merged: ${pr.title}`, - url: pr.url, - }; - } - return null; + if (!pr) return null; + + if (pr.state === "open") { + return { + label: "PR open", + colorClass: "text-emerald-600 dark:text-emerald-300/90", + tooltip: `#${pr.number} PR open: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "closed") { + return { + label: "PR closed", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltip: `#${pr.number} PR closed: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "merged") { + return { + label: "PR merged", + colorClass: "text-violet-600 dark:text-violet-300/90", + tooltip: `#${pr.number} PR merged: ${pr.title}`, + url: pr.url, + }; + } + return null; } function resolveThreadPr( - threadBranch: string | null, - gitStatus: GitStatusResult | null, + threadBranch: string | null, + gitStatus: GitStatusResult | null, ): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { - return null; - } + if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + return null; + } - return gitStatus.pr ?? null; + return gitStatus.pr ?? null; } const SidebarThreadMetaCluster = memo(function SidebarThreadMetaCluster(props: { - appSettingsConfirmThreadArchive: boolean; - confirmArchiveButtonRef: React.RefObject; - handleArchiveImmediateClick: (event: React.MouseEvent) => void; - handleConfirmArchiveClick: (event: React.MouseEvent) => void; - handleStartArchiveConfirmation: (event: React.MouseEvent) => void; - isConfirmingArchive: boolean; - isHighlighted: boolean; - isRemoteThread: boolean; - stopPropagationOnPointerDown: (event: React.PointerEvent) => void; - threadEnvironmentLabel: string | null; - threadId: ThreadId; - threadKey: string; - threadRef: ScopedThreadRef; - threadTitle: string; + appSettingsConfirmThreadArchive: boolean; + confirmArchiveButtonRef: React.RefObject; + handleArchiveImmediateClick: (event: React.MouseEvent) => void; + handleConfirmArchiveClick: (event: React.MouseEvent) => void; + handleStartArchiveConfirmation: (event: React.MouseEvent) => void; + isConfirmingArchive: boolean; + isHighlighted: boolean; + isRemoteThread: boolean; + stopPropagationOnPointerDown: (event: React.PointerEvent) => void; + threadEnvironmentLabel: string | null; + threadId: ThreadId; + threadKey: string; + threadRef: ScopedThreadRef; + threadTitle: string; }) { - const { - appSettingsConfirmThreadArchive, - confirmArchiveButtonRef, - handleArchiveImmediateClick, - handleConfirmArchiveClick, - handleStartArchiveConfirmation, - isConfirmingArchive, - isHighlighted, - isRemoteThread, - stopPropagationOnPointerDown, - threadEnvironmentLabel, - threadId, - threadKey, - threadRef, - threadTitle, - } = props; - const jumpLabel = useSidebarThreadJumpLabel(threadKey); - const metaSnapshot = useStore( - useMemo(() => createSidebarThreadMetaSnapshotSelectorByRef(threadRef), [threadRef]), - ); - const isThreadRunning = metaSnapshot?.isRunning ?? false; - const hidden = isConfirmingArchive && !isThreadRunning; - const relativeTimestamp = useMemo( - () => (metaSnapshot ? formatRelativeTimeLabel(metaSnapshot.activityTimestamp) : null), - [metaSnapshot], - ); - const isConfirmingArchiveVisible = isConfirmingArchive && !isThreadRunning; - - const archiveControl = useMemo(() => { - if (isConfirmingArchiveVisible) { - return ( - - ); - } - - if (isThreadRunning) { - return null; - } - - if (appSettingsConfirmThreadArchive) { - return ( -
    - -
    - ); - } - - return ( - - - - - } - /> - Archive - - ); - }, [ - appSettingsConfirmThreadArchive, - confirmArchiveButtonRef, - handleArchiveImmediateClick, - handleConfirmArchiveClick, - handleStartArchiveConfirmation, - isConfirmingArchiveVisible, - isThreadRunning, - stopPropagationOnPointerDown, - threadId, - threadTitle, - ]); - - return ( - <> - {archiveControl} - - - ); + const { + appSettingsConfirmThreadArchive, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchive, + isHighlighted, + isRemoteThread, + stopPropagationOnPointerDown, + threadEnvironmentLabel, + threadId, + threadKey, + threadRef, + threadTitle, + } = props; + const jumpLabel = useSidebarThreadJumpLabel(threadKey); + const metaSnapshot = useStore( + useMemo(() => createSidebarThreadMetaSnapshotSelectorByRef(threadRef), [threadRef]), + ); + const isThreadRunning = metaSnapshot?.isRunning ?? false; + const hidden = isConfirmingArchive && !isThreadRunning; + const relativeTimestamp = useMemo( + () => (metaSnapshot ? formatRelativeTimeLabel(metaSnapshot.activityTimestamp) : null), + [metaSnapshot], + ); + const isConfirmingArchiveVisible = isConfirmingArchive && !isThreadRunning; + + const archiveControl = useMemo(() => { + if (isConfirmingArchiveVisible) { + return ( + + ); + } + + if (isThreadRunning) { + return null; + } + + if (appSettingsConfirmThreadArchive) { + return ( +
    + +
    + ); + } + + return ( + + + + + } + /> + Archive + + ); + }, [ + appSettingsConfirmThreadArchive, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchiveVisible, + isThreadRunning, + stopPropagationOnPointerDown, + threadId, + threadTitle, + ]); + + return ( + <> + {archiveControl} + + + ); }); const SidebarThreadStatusIndicator = memo(function SidebarThreadStatusIndicator(props: { - threadKey: string; - threadRef: ScopedThreadRef; + threadKey: string; + threadRef: ScopedThreadRef; }) { - const { threadKey, threadRef } = props; - const statusInput = useSidebarThreadStatusInput(threadRef); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const threadStatus = useMemo( - () => - statusInput - ? resolveThreadStatusPill({ - thread: { - ...statusInput, - lastVisitedAt, - }, - }) - : null, - [lastVisitedAt, statusInput], - ); - - return threadStatus ? : null; + const { threadKey, threadRef } = props; + const statusInput = useSidebarThreadStatusInput(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const threadStatus = useMemo( + () => + statusInput + ? resolveThreadStatusPill({ + thread: { + ...statusInput, + lastVisitedAt, + }, + }) + : null, + [lastVisitedAt, statusInput], + ); + + return threadStatus ? : null; }); const SidebarThreadTerminalStatusIndicator = memo( - function SidebarThreadTerminalStatusIndicator(props: { threadRef: ScopedThreadRef }) { - const { threadRef } = props; - const runningTerminalIds = useTerminalStateStore( - (state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, - ); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - - return terminalStatus ? ( - - - - ) : null; - }, + function SidebarThreadTerminalStatusIndicator(props: { threadRef: ScopedThreadRef }) { + const { threadRef } = props; + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, + ); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + + return terminalStatus ? ( + + + + ) : null; + }, ); interface SidebarThreadRowProps { - threadKey: string; - project: SidebarProjectSnapshot; + threadKey: string; + project: SidebarProjectSnapshot; } const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { - const { threadKey, project } = props; - const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); - const threadSortOrder = useSettings( - (settings) => settings.sidebarThreadSortOrder, - ); - const appSettingsConfirmThreadDelete = useSettings( - (settings) => settings.confirmThreadDelete, - ); - const appSettingsConfirmThreadArchive = useSettings( - (settings) => settings.confirmThreadArchive, - ); - const router = useRouter(); - const { archiveThread, deleteThread } = useThreadActions(); - const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); - const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); - const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); - const clearSelection = useThreadSelectionStore((state) => state.clearSelection); - const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); - const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); - const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ - threadId: ThreadId; - }>({ - onCopy: (ctx) => { - toastManager.add({ - type: "success", - title: "Thread ID copied", - description: ctx.threadId, - }); - }, - onError: (error) => { - toastManager.add({ - type: "error", - title: "Failed to copy thread ID", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }, - }); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ - path: string; - }>({ - onCopy: (ctx) => { - toastManager.add({ - type: "success", - title: "Path copied", - description: ctx.path, - }); - }, - onError: (error) => { - toastManager.add({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }, - }); - const [isRenaming, setIsRenaming] = useState(false); - const [renamingTitle, setRenamingTitle] = useState(""); - const [isConfirmingArchive, setIsConfirmingArchive] = useState(false); - const renamingCommittedRef = useRef(false); - const renamingInputRef = useRef(null); - const confirmArchiveButtonRef = useRef(null); - - const thread = useStore( - useMemo(() => createSidebarThreadRowSnapshotSelectorByRef(threadRef), [threadRef]), - ); - const isActive = useSidebarIsActiveThread(threadKey); - if (!threadRef || !thread) { - return null; - } - const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); - const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = useSavedEnvironmentRuntimeStore( - (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, - ); - const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( - (s) => s.byId[thread.environmentId]?.label ?? null, - ); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") - : null; - // For grouped projects, the thread may belong to a different environment - // than the representative project. Look up the thread's own project cwd - // so git status (and thus PR detection) queries the correct path. - const threadProjectCwd = useStore( - useMemo( - () => (state: import("../store").AppState) => - selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? - null, - [thread.environmentId, thread.projectId], - ), - ); - const gitCwd = thread.worktreePath ?? threadProjectCwd ?? project?.cwd ?? null; - const gitStatus = useGitStatus({ - environmentId: thread.environmentId, - cwd: thread.branch != null ? gitCwd : null, - }); - const isHighlighted = isActive || isSelected; - const pr = resolveThreadPr(thread.branch, gitStatus.data); - const prStatus = prStatusIndicator(pr); - const clearConfirmingArchive = useCallback(() => { - setIsConfirmingArchive(false); - }, []); - const handleMouseLeave = useCallback(() => { - clearConfirmingArchive(); - }, [clearConfirmingArchive]); - const handleBlurCapture = useCallback( - (event: React.FocusEvent) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - clearConfirmingArchive(); - }); - }, - [clearConfirmingArchive], - ); - const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { - event.preventDefault(); - event.stopPropagation(); - - const api = readLocalApi(); - 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.", - }); - }); - }, []); - const navigateToThread = useCallback( - (targetThreadRef: ScopedThreadRef) => { - if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { - clearSelection(); - } - setSelectionAnchor(scopedThreadKey(targetThreadRef)); - void router.navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(targetThreadRef), - }); - }, - [clearSelection, router, setSelectionAnchor], - ); - const attemptArchiveThread = useCallback(async () => { - try { - await archiveThread(threadRef); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to archive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - }, [archiveThread, threadRef]); - const cancelRename = useCallback(() => { - setIsRenaming(false); - setRenamingTitle(""); - renamingCommittedRef.current = false; - renamingInputRef.current = null; - }, []); - const commitRename = useCallback(async () => { - const finishRename = () => { - setIsRenaming(false); - renamingCommittedRef.current = false; - renamingInputRef.current = null; - }; - - const trimmed = renamingTitle.trim(); - if (trimmed.length === 0) { - toastManager.add({ - type: "warning", - title: "Thread title cannot be empty", - }); - finishRename(); - return; - } - if (trimmed === thread.title) { - finishRename(); - return; - } - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) { - finishRename(); - return; - } - try { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadRef.threadId, - title: trimmed, - }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - finishRename(); - }, [renamingTitle, thread.title, threadRef]); - const handleMultiSelectContextMenu = useCallback( - async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; - if (threadKeys.length === 0) return; - const count = threadKeys.length; - - const clicked = await api.contextMenu.show( - [ - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], - position, - ); - - if (clicked === "mark-unread") { - const appState = useStore.getState(); - for (const selectedThreadKey of threadKeys) { - const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); - if (!selectedThreadRef) continue; - const selectedThread = selectSidebarThreadSummaryByRef(appState, selectedThreadRef); - markThreadUnread(selectedThreadKey, selectedThread?.latestTurn?.completedAt); - } - clearSelection(); - return; - } - - if (clicked !== "delete") return; - - if (appSettingsConfirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete ${count} thread${count === 1 ? "" : "s"}?`, - "This permanently clears conversation history for these threads.", - ].join("\n"), - ); - if (!confirmed) return; - } - - const deletedThreadKeys = new Set(threadKeys); - for (const selectedThreadKey of threadKeys) { - const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); - if (!selectedThreadRef) continue; - await deleteThread(selectedThreadRef, { deletedThreadKeys }); - } - removeFromSelection(threadKeys); - }, - [ - appSettingsConfirmThreadDelete, - clearSelection, - deleteThread, - markThreadUnread, - removeFromSelection, - ], - ); - const handleThreadContextMenu = useCallback( - async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - const threadWorkspacePath = thread.worktreePath ?? project?.cwd ?? null; - const currentThreadSummary = selectSidebarThreadSummaryByRef(useStore.getState(), threadRef); - const clicked = await api.contextMenu.show( - [ - { id: "rename", label: "Rename thread" }, - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy Path" }, - { id: "copy-thread-id", label: "Copy Thread ID" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, - ); - - if (clicked === "rename") { - setIsRenaming(true); - setRenamingTitle(thread.title); - renamingCommittedRef.current = false; - return; - } - - if (clicked === "mark-unread") { - markThreadUnread(threadKey, currentThreadSummary?.latestTurn?.completedAt); - return; - } - if (clicked === "copy-path") { - if (!threadWorkspacePath) { - toastManager.add({ - type: "error", - title: "Path unavailable", - description: "This thread does not have a workspace path to copy.", - }); - return; - } - copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); - return; - } - if (clicked === "copy-thread-id") { - copyThreadIdToClipboard(thread.id, { threadId: thread.id }); - return; - } - if (clicked !== "delete") return; - if (appSettingsConfirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ); - if (!confirmed) { - return; - } - } - await deleteThread(threadRef); - }, - [ - appSettingsConfirmThreadDelete, - copyPathToClipboard, - copyThreadIdToClipboard, - deleteThread, - markThreadUnread, - project?.cwd, - thread, - threadKey, - threadRef, - ], - ); - const handleRowClick = useCallback( - (event: React.MouseEvent) => { - const isMac = isMacPlatform(navigator.platform); - const isModClick = isMac ? event.metaKey : event.ctrlKey; - const isShiftClick = event.shiftKey; - const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size; - - if (isModClick) { - event.preventDefault(); - toggleThreadSelection(threadKey); - return; - } - - if (isShiftClick) { - event.preventDefault(); - const orderedProjectThreadKeys = project - ? sortThreadsForSidebar( - selectSidebarThreadsForProjectRefs( - useStore.getState(), - project.memberProjectRefs, - ).filter((projectThread) => projectThread.archivedAt === null), - threadSortOrder, - ).map((projectThread) => - scopedThreadKey(scopeThreadRef(projectThread.environmentId, projectThread.id)), - ) - : [threadKey]; - rangeSelectTo(threadKey, orderedProjectThreadKeys); - return; - } - - if (currentSelectionCount > 0) { - clearSelection(); - } - navigateToThread(threadRef); - }, - [ - clearSelection, - navigateToThread, - project, - rangeSelectTo, - threadKey, - threadRef, - threadSortOrder, - toggleThreadSelection, - ], - ); - const handleRowKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(threadRef); - }, - [navigateToThread, threadRef], - ); - const handleRowContextMenu = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - if (hasSelection && isSelected) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - return; - } - - if (hasSelection) { - clearSelection(); - } - void handleThreadContextMenu({ - x: event.clientX, - y: event.clientY, - }); - }, - [ - clearSelection, - handleMultiSelectContextMenu, - handleThreadContextMenu, - hasSelection, - isSelected, - ], - ); - const handlePrClick = useCallback( - (event: React.MouseEvent) => { - if (!prStatus) return; - openPrLink(event, prStatus.url); - }, - [openPrLink, prStatus], - ); - const handleRenameInputRef = useCallback( - (element: HTMLInputElement | null) => { - if (element && renamingInputRef.current !== element) { - renamingInputRef.current = element; - element.focus(); - element.select(); - } - }, - [renamingInputRef], - ); - const handleRenameInputChange = useCallback( - (event: React.ChangeEvent) => { - setRenamingTitle(event.target.value); - }, - [setRenamingTitle], - ); - const handleRenameInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(); - } else if (event.key === "Escape") { - event.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }, - [cancelRename, commitRename, renamingCommittedRef], - ); - const handleRenameInputBlur = useCallback(() => { - if (!renamingCommittedRef.current) { - void commitRename(); - } - }, [commitRename, renamingCommittedRef]); - const handleRenameInputClick = useCallback((event: React.MouseEvent) => { - event.stopPropagation(); - }, []); - const stopPropagationOnPointerDown = useCallback( - (event: React.PointerEvent) => { - event.stopPropagation(); - }, - [], - ); - const handleConfirmArchiveClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - clearConfirmingArchive(); - void attemptArchiveThread(); - }, - [attemptArchiveThread, clearConfirmingArchive], - ); - const handleStartArchiveConfirmation = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - setIsConfirmingArchive(true); - requestAnimationFrame(() => { - confirmArchiveButtonRef.current?.focus(); - }); - }, - [], - ); - const handleArchiveImmediateClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - void attemptArchiveThread(); - }, - [attemptArchiveThread], - ); - const rowButtonRender = useMemo(() =>
    , []); - - return ( - - -
    - {prStatus && ( - - - - - } - /> - {prStatus.tooltip} - - )} - - {isRenaming ? ( - - ) : ( - {thread.title} - )} -
    -
    - -
    - -
    -
    -
    -
    - ); + const { threadKey, project } = props; + const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); + const threadSortOrder = useSettings( + (settings) => settings.sidebarThreadSortOrder, + ); + const appSettingsConfirmThreadDelete = useSettings( + (settings) => settings.confirmThreadDelete, + ); + const appSettingsConfirmThreadArchive = useSettings( + (settings) => settings.confirmThreadArchive, + ); + const router = useRouter(); + const { archiveThread, deleteThread } = useThreadActions(); + const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); + const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); + const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); + const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ + threadId: ThreadId; + }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: ctx.threadId, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ + path: string; + }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Path copied", + description: ctx.path, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); + const [isRenaming, setIsRenaming] = useState(false); + const [renamingTitle, setRenamingTitle] = useState(""); + const [isConfirmingArchive, setIsConfirmingArchive] = useState(false); + const renamingCommittedRef = useRef(false); + const renamingInputRef = useRef(null); + const confirmArchiveButtonRef = useRef(null); + + const thread = useStore( + useMemo(() => createSidebarThreadRowSnapshotSelectorByRef(threadRef), [threadRef]), + ); + const isActive = useSidebarIsActiveThread(threadKey); + if (!threadRef || !thread) { + return null; + } + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = useSavedEnvironmentRuntimeStore( + (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, + ); + const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( + (s) => s.byId[thread.environmentId]?.label ?? null, + ); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") + : null; + // For grouped projects, the thread may belong to a different environment + // than the representative project. Look up the thread's own project cwd + // so git status (and thus PR detection) queries the correct path. + const threadProjectCwd = useStore( + useMemo( + () => (state: import("../store").AppState) => + selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? + null, + [thread.environmentId, thread.projectId], + ), + ); + const gitCwd = thread.worktreePath ?? threadProjectCwd ?? project?.cwd ?? null; + const gitStatus = useGitStatus({ + environmentId: thread.environmentId, + cwd: thread.branch != null ? gitCwd : null, + }); + const isHighlighted = isActive || isSelected; + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr); + const clearConfirmingArchive = useCallback(() => { + setIsConfirmingArchive(false); + }, []); + const handleMouseLeave = useCallback(() => { + clearConfirmingArchive(); + }, [clearConfirmingArchive]); + const handleBlurCapture = useCallback( + (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + clearConfirmingArchive(); + }); + }, + [clearConfirmingArchive], + ); + const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + 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.", + }); + }); + }, []); + const navigateToThread = useCallback( + (targetThreadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(targetThreadRef)); + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(targetThreadRef), + }); + }, + [clearSelection, router, setSelectionAnchor], + ); + const attemptArchiveThread = useCallback(async () => { + try { + await archiveThread(threadRef); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, [archiveThread, threadRef]); + const cancelRename = useCallback(() => { + setIsRenaming(false); + setRenamingTitle(""); + renamingCommittedRef.current = false; + renamingInputRef.current = null; + }, []); + const commitRename = useCallback(async () => { + const finishRename = () => { + setIsRenaming(false); + renamingCommittedRef.current = false; + renamingInputRef.current = null; + }; + + const trimmed = renamingTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Thread title cannot be empty", + }); + finishRename(); + return; + } + if (trimmed === thread.title) { + finishRename(); + return; + } + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + finishRename(); + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: threadRef.threadId, + title: trimmed, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + finishRename(); + }, [renamingTitle, thread.title, threadRef]); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + if (threadKeys.length === 0) return; + const count = threadKeys.length; + + const clicked = await api.contextMenu.show( + [ + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ); + + if (clicked === "mark-unread") { + const appState = useStore.getState(); + for (const selectedThreadKey of threadKeys) { + const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); + if (!selectedThreadRef) continue; + const selectedThread = selectSidebarThreadSummaryByRef(appState, selectedThreadRef); + markThreadUnread(selectedThreadKey, selectedThread?.latestTurn?.completedAt); + } + clearSelection(); + return; + } + + if (clicked !== "delete") return; + + if (appSettingsConfirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ); + if (!confirmed) return; + } + + const deletedThreadKeys = new Set(threadKeys); + for (const selectedThreadKey of threadKeys) { + const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); + if (!selectedThreadRef) continue; + await deleteThread(selectedThreadRef, { deletedThreadKeys }); + } + removeFromSelection(threadKeys); + }, + [ + appSettingsConfirmThreadDelete, + clearSelection, + deleteThread, + markThreadUnread, + removeFromSelection, + ], + ); + const handleThreadContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadWorkspacePath = thread.worktreePath ?? project?.cwd ?? null; + const currentThreadSummary = selectSidebarThreadSummaryByRef(useStore.getState(), threadRef); + const clicked = await api.contextMenu.show( + [ + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy Path" }, + { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + + if (clicked === "rename") { + setIsRenaming(true); + setRenamingTitle(thread.title); + renamingCommittedRef.current = false; + return; + } + + if (clicked === "mark-unread") { + markThreadUnread(threadKey, currentThreadSummary?.latestTurn?.completedAt); + return; + } + if (clicked === "copy-path") { + if (!threadWorkspacePath) { + toastManager.add({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }); + return; + } + copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + return; + } + if (clicked === "copy-thread-id") { + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + return; + } + if (clicked !== "delete") return; + if (appSettingsConfirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + } + await deleteThread(threadRef); + }, + [ + appSettingsConfirmThreadDelete, + copyPathToClipboard, + copyThreadIdToClipboard, + deleteThread, + markThreadUnread, + project?.cwd, + thread, + threadKey, + threadRef, + ], + ); + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const isShiftClick = event.shiftKey; + const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size; + + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + + if (isShiftClick) { + event.preventDefault(); + const orderedProjectThreadKeys = project + ? sortThreadsForSidebar( + selectSidebarThreadsForProjectRefs( + useStore.getState(), + project.memberProjectRefs, + ).filter((projectThread) => projectThread.archivedAt === null), + threadSortOrder, + ).map((projectThread) => + scopedThreadKey(scopeThreadRef(projectThread.environmentId, projectThread.id)), + ) + : [threadKey]; + rangeSelectTo(threadKey, orderedProjectThreadKeys); + return; + } + + if (currentSelectionCount > 0) { + clearSelection(); + } + navigateToThread(threadRef); + }, + [ + clearSelection, + navigateToThread, + project, + rangeSelectTo, + threadKey, + threadRef, + threadSortOrder, + toggleThreadSelection, + ], + ); + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }, + [navigateToThread, threadRef], + ); + const handleRowContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (hasSelection && isSelected) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + return; + } + + if (hasSelection) { + clearSelection(); + } + void handleThreadContextMenu({ + x: event.clientX, + y: event.clientY, + }); + }, + [ + clearSelection, + handleMultiSelectContextMenu, + handleThreadContextMenu, + hasSelection, + isSelected, + ], + ); + const handlePrClick = useCallback( + (event: React.MouseEvent) => { + if (!prStatus) return; + openPrLink(event, prStatus.url); + }, + [openPrLink, prStatus], + ); + const handleRenameInputRef = useCallback( + (element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + } + }, + [renamingInputRef], + ); + const handleRenameInputChange = useCallback( + (event: React.ChangeEvent) => { + setRenamingTitle(event.target.value); + }, + [setRenamingTitle], + ); + const handleRenameInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(); + } else if (event.key === "Escape") { + event.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }, + [cancelRename, commitRename, renamingCommittedRef], + ); + const handleRenameInputBlur = useCallback(() => { + if (!renamingCommittedRef.current) { + void commitRename(); + } + }, [commitRename, renamingCommittedRef]); + const handleRenameInputClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + const stopPropagationOnPointerDown = useCallback( + (event: React.PointerEvent) => { + event.stopPropagation(); + }, + [], + ); + const handleConfirmArchiveClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearConfirmingArchive(); + void attemptArchiveThread(); + }, + [attemptArchiveThread, clearConfirmingArchive], + ); + const handleStartArchiveConfirmation = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsConfirmingArchive(true); + requestAnimationFrame(() => { + confirmArchiveButtonRef.current?.focus(); + }); + }, + [], + ); + const handleArchiveImmediateClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void attemptArchiveThread(); + }, + [attemptArchiveThread], + ); + const rowButtonRender = useMemo(() =>
    , []); + + return ( + + +
    + {prStatus && ( + + + + + } + /> + {prStatus.tooltip} + + )} + + {isRenaming ? ( + + ) : ( + {thread.title} + )} +
    +
    + +
    + +
    +
    +
    +
    + ); }); interface SidebarProjectThreadListProps { - project: SidebarProjectSnapshot; - projectExpanded: boolean; - hasOverflowingThreads: boolean; - hiddenThreadKeys: readonly string[]; - renderedThreadKeys: readonly string[]; - showEmptyThreadState: boolean; - shouldShowThreadPanel: boolean; - isThreadListExpanded: boolean; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + project: SidebarProjectSnapshot; + projectExpanded: boolean; + hasOverflowingThreads: boolean; + hiddenThreadKeys: readonly string[]; + renderedThreadKeys: readonly string[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; + isThreadListExpanded: boolean; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; } const SidebarProjectThreadList = memo(function SidebarProjectThreadList( - props: SidebarProjectThreadListProps, + props: SidebarProjectThreadListProps, ) { - const { - project, - projectExpanded, - hasOverflowingThreads, - hiddenThreadKeys, - renderedThreadKeys, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - attachThreadListAutoAnimateRef, - } = props; - const showMoreButtonRender = useMemo(() => -
    - } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - -
    - ); + {project.environmentPresence === "remote-only" && ( + + + } + > + + + + Remote environment: {project.remoteEnvironmentLabels.join(", ")} + + + )} + + + + + } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + + + ); }); interface SidebarProjectItemProps { - project: SidebarProjectSnapshot; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - dragInProgressRef: React.RefObject; - suppressProjectClickAfterDragRef: React.RefObject; - suppressProjectClickForContextMenuRef: React.RefObject; - isManualProjectSorting: boolean; - dragHandleProps: SortableProjectHandleProps | null; + project: SidebarProjectSnapshot; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + isManualProjectSorting: boolean; + dragHandleProps: SortableProjectHandleProps | null; } const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { - const { - project, - attachThreadListAutoAnimateRef, - dragInProgressRef, - suppressProjectClickAfterDragRef, - suppressProjectClickForContextMenuRef, - isManualProjectSorting, - dragHandleProps, - } = props; - - return ( - <> - - - - - ); + const { + project, + attachThreadListAutoAnimateRef, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + isManualProjectSorting, + dragHandleProps, + } = props; + + return ( + <> + + + + + ); }); const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { - return ( - - - - ); + return ( + + + + ); }); function T3Wordmark() { - return ( - - - - ); + return ( + + + + ); } type SortableProjectHandleProps = Pick< - ReturnType, - "attributes" | "listeners" | "setActivatorNodeRef" + ReturnType, + "attributes" | "listeners" | "setActivatorNodeRef" >; function ProjectSortMenu({ - projectSortOrder, - threadSortOrder, - onProjectSortOrderChange, - onThreadSortOrderChange, + projectSortOrder, + threadSortOrder, + onProjectSortOrderChange, + onThreadSortOrderChange, }: { - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; - onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; + onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; }) { - return ( - - - - } - > - - - Sort projects - - - -
    - Sort projects -
    - { - onProjectSortOrderChange(value as SidebarProjectSortOrder); - }} - > - {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( - ([value, label]) => ( - - {label} - - ), - )} - -
    - -
    - Sort threads -
    - { - onThreadSortOrderChange(value as SidebarThreadSortOrder); - }} - > - {( - Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> - ).map(([value, label]) => ( - - {label} - - ))} - -
    -
    -
    - ); + return ( + + + + } + > + + + Sort projects + + + +
    + Sort projects +
    + { + onProjectSortOrderChange(value as SidebarProjectSortOrder); + }} + > + {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( + ([value, label]) => ( + + {label} + + ), + )} + +
    + +
    + Sort threads +
    + { + onThreadSortOrderChange(value as SidebarThreadSortOrder); + }} + > + {( + Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> + ).map(([value, label]) => ( + + {label} + + ))} + +
    +
    +
    + ); } function SortableProjectItem({ - projectId, - disabled = false, - children, + projectId, + disabled = false, + children, }: { - projectId: string; - disabled?: boolean; - children: (handleProps: SortableProjectHandleProps) => React.ReactNode; + projectId: string; + disabled?: boolean; + children: (handleProps: SortableProjectHandleProps) => React.ReactNode; }) { - const { - attributes, - listeners, - setActivatorNodeRef, - setNodeRef, - transform, - transition, - isDragging, - isOver, - } = useSortable({ id: projectId, disabled }); - return ( -
  • - {children({ attributes, listeners, setActivatorNodeRef })} -
  • - ); + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ id: projectId, disabled }); + return ( +
  • + {children({ attributes, listeners, setActivatorNodeRef })} +
  • + ); } const SidebarChromeHeader = memo(function SidebarChromeHeader({ - isElectron, + isElectron, }: { - isElectron: boolean; + isElectron: boolean; }) { - const wordmark = ( -
    - - - - - - Code - - - {APP_STAGE_LABEL} - - - } - /> - - Version {APP_VERSION} - - -
    - ); - - return isElectron ? ( - - {wordmark} - - ) : ( - {wordmark} - ); + const wordmark = ( +
    + + + + + + Code + + + {APP_STAGE_LABEL} + + + } + /> + + Version {APP_VERSION} + + +
    + ); + + return isElectron ? ( + + {wordmark} + + ) : ( + {wordmark} + ); }); const SidebarChromeFooter = memo(function SidebarChromeFooter() { - const navigate = useNavigate(); - const handleSettingsClick = useCallback(() => { - void navigate({ to: "/settings" }); - }, [navigate]); - - return ( - - - - - - - Settings - - - - - ); + const navigate = useNavigate(); + const handleSettingsClick = useCallback(() => { + void navigate({ to: "/settings" }); + }, [navigate]); + + return ( + + + + + + + Settings + + + + + ); }); interface SidebarProjectsContentProps { - sortedProjectKeys: readonly LogicalProjectKey[]; - sidebarProjectByKey: ReadonlyMap; - showArm64IntelBuildWarning: boolean; - arm64IntelBuildWarningDescription: string | null; - desktopUpdateButtonAction: "download" | "install" | "none"; - desktopUpdateButtonDisabled: boolean; - handleDesktopUpdateButtonClick: () => void; - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - updateSettings: ReturnType["updateSettings"]; - shouldShowProjectPathEntry: boolean; - handleStartAddProject: () => void; - isElectron: boolean; - isPickingFolder: boolean; - isAddingProject: boolean; - handlePickFolder: () => Promise; - addProjectInputRef: React.RefObject; - addProjectError: string | null; - newCwd: string; - setNewCwd: React.Dispatch>; - setAddProjectError: React.Dispatch>; - handleAddProject: () => void; - setAddingProject: React.Dispatch>; - canAddProject: boolean; - isManualProjectSorting: boolean; - projectDnDSensors: ReturnType; - projectCollisionDetection: CollisionDetection; - handleProjectDragStart: (event: DragStartEvent) => void; - handleProjectDragEnd: (event: DragEndEvent) => void; - handleProjectDragCancel: (event: DragCancelEvent) => void; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - dragInProgressRef: React.RefObject; - suppressProjectClickAfterDragRef: React.RefObject; - suppressProjectClickForContextMenuRef: React.RefObject; - attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; - projectsLength: number; + sortedProjectKeys: readonly LogicalProjectKey[]; + sidebarProjectByKey: ReadonlyMap; + showArm64IntelBuildWarning: boolean; + arm64IntelBuildWarningDescription: string | null; + desktopUpdateButtonAction: "download" | "install" | "none"; + desktopUpdateButtonDisabled: boolean; + handleDesktopUpdateButtonClick: () => void; + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + updateSettings: ReturnType["updateSettings"]; + shouldShowProjectPathEntry: boolean; + handleStartAddProject: () => void; + isElectron: boolean; + isPickingFolder: boolean; + isAddingProject: boolean; + handlePickFolder: () => Promise; + addProjectInputRef: React.RefObject; + addProjectError: string | null; + newCwd: string; + setNewCwd: React.Dispatch>; + setAddProjectError: React.Dispatch>; + handleAddProject: () => void; + setAddingProject: React.Dispatch>; + canAddProject: boolean; + isManualProjectSorting: boolean; + projectDnDSensors: ReturnType; + projectCollisionDetection: CollisionDetection; + handleProjectDragStart: (event: DragStartEvent) => void; + handleProjectDragEnd: (event: DragEndEvent) => void; + handleProjectDragCancel: (event: DragCancelEvent) => void; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; + projectsLength: number; } const SidebarProjectsContent = memo(function SidebarProjectsContent( - props: SidebarProjectsContentProps, + props: SidebarProjectsContentProps, ) { - const { - sortedProjectKeys, - sidebarProjectByKey, - showArm64IntelBuildWarning, - arm64IntelBuildWarningDescription, - desktopUpdateButtonAction, - desktopUpdateButtonDisabled, - handleDesktopUpdateButtonClick, - projectSortOrder, - threadSortOrder, - updateSettings, - shouldShowProjectPathEntry, - handleStartAddProject, - isElectron, - isPickingFolder, - isAddingProject, - handlePickFolder, - addProjectInputRef, - addProjectError, - newCwd, - setNewCwd, - setAddProjectError, - handleAddProject, - setAddingProject, - canAddProject, - isManualProjectSorting, - projectDnDSensors, - projectCollisionDetection, - handleProjectDragStart, - handleProjectDragEnd, - handleProjectDragCancel, - attachThreadListAutoAnimateRef, - dragInProgressRef, - suppressProjectClickAfterDragRef, - suppressProjectClickForContextMenuRef, - attachProjectListAutoAnimateRef, - projectsLength, - } = props; - const handleProjectSortOrderChange = useCallback( - (sortOrder: SidebarProjectSortOrder) => { - updateSettings({ sidebarProjectSortOrder: sortOrder }); - }, - [updateSettings], - ); - const handleThreadSortOrderChange = useCallback( - (sortOrder: SidebarThreadSortOrder) => { - updateSettings({ sidebarThreadSortOrder: sortOrder }); - }, - [updateSettings], - ); - const handleAddProjectInputChange = useCallback( - (event: React.ChangeEvent) => { - setNewCwd(event.target.value); - setAddProjectError(null); - }, - [setAddProjectError, setNewCwd], - ); - const handleAddProjectInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } - }, - [handleAddProject, setAddProjectError, setAddingProject], - ); - const handleBrowseForFolderClick = useCallback(() => { - void handlePickFolder(); - }, [handlePickFolder]); - - return ( - - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null} - -
    - - Projects - -
    - - - - } - > - - - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - - -
    -
    - {shouldShowProjectPathEntry && ( -
    - {isElectron && ( - - )} -
    - - -
    - {addProjectError && ( -

    - {addProjectError} -

    - )} -
    - )} - - {isManualProjectSorting ? ( - - - - {sortedProjectKeys.map((projectKey) => - (() => { - const project = sidebarProjectByKey.get(projectKey); - if (!project) { - return null; - } - return ( - - {(dragHandleProps) => ( - - )} - - ); - })(), - )} - - - - ) : ( - - {sortedProjectKeys.map((projectKey) => - (() => { - const project = sidebarProjectByKey.get(projectKey); - if (!project) { - return null; - } - return ( - - ); - })(), - )} - - )} - - {projectsLength === 0 && !shouldShowProjectPathEntry && ( -
    - No projects yet -
    - )} -
    -
    - ); + const { + sortedProjectKeys, + sidebarProjectByKey, + showArm64IntelBuildWarning, + arm64IntelBuildWarningDescription, + desktopUpdateButtonAction, + desktopUpdateButtonDisabled, + handleDesktopUpdateButtonClick, + projectSortOrder, + threadSortOrder, + updateSettings, + shouldShowProjectPathEntry, + handleStartAddProject, + isElectron, + isPickingFolder, + isAddingProject, + handlePickFolder, + addProjectInputRef, + addProjectError, + newCwd, + setNewCwd, + setAddProjectError, + handleAddProject, + setAddingProject, + canAddProject, + isManualProjectSorting, + projectDnDSensors, + projectCollisionDetection, + handleProjectDragStart, + handleProjectDragEnd, + handleProjectDragCancel, + attachThreadListAutoAnimateRef, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + attachProjectListAutoAnimateRef, + projectsLength, + } = props; + const handleProjectSortOrderChange = useCallback( + (sortOrder: SidebarProjectSortOrder) => { + updateSettings({ sidebarProjectSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadSortOrderChange = useCallback( + (sortOrder: SidebarThreadSortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleAddProjectInputChange = useCallback( + (event: React.ChangeEvent) => { + setNewCwd(event.target.value); + setAddProjectError(null); + }, + [setAddProjectError, setNewCwd], + ); + const handleAddProjectInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") handleAddProject(); + if (event.key === "Escape") { + setAddingProject(false); + setAddProjectError(null); + } + }, + [handleAddProject, setAddProjectError, setAddingProject], + ); + const handleBrowseForFolderClick = useCallback(() => { + void handlePickFolder(); + }, [handlePickFolder]); + + return ( + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( + + + + Intel build on Apple Silicon + {arm64IntelBuildWarningDescription} + {desktopUpdateButtonAction !== "none" ? ( + + + + ) : null} + + + ) : null} + +
    + + Projects + +
    + + + + } + > + + + + {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} + + +
    +
    + {shouldShowProjectPathEntry && ( +
    + {isElectron && ( + + )} +
    + + +
    + {addProjectError && ( +

    + {addProjectError} +

    + )} +
    + )} + + {isManualProjectSorting ? ( + + + + {sortedProjectKeys.map((projectKey) => + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; + } + return ( + + {(dragHandleProps) => ( + + )} + + ); + })(), + )} + + + + ) : ( + + {sortedProjectKeys.map((projectKey) => + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; + } + return ( + + ); + })(), + )} + + )} + + {projectsLength === 0 && !shouldShowProjectPathEntry && ( +
    + No projects yet +
    + )} +
    +
    + ); }); export default function Sidebar() { - const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const reorderProjects = useUiStateStore((store) => store.reorderProjects); - const navigate = useNavigate(); - const isOnSettings = useLocation({ select: (loc) => loc.pathname.startsWith("/settings") }); - const settingsPathname = useLocation({ - select: (loc) => (loc.pathname.startsWith("/settings") ? loc.pathname : "/settings"), - }); - const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); - const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); - const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); - const { updateSettings } = useUpdateSettings(); - const { handleNewThread } = useNewThreadHandler(); - const [addingProject, setAddingProject] = useState(false); - const [newCwd, setNewCwd] = useState(""); - const [isPickingFolder, setIsPickingFolder] = useState(false); - const [isAddingProject, setIsAddingProject] = useState(false); - const [addProjectError, setAddProjectError] = useState(null); - const addProjectInputRef = useRef(null); - const dragInProgressRef = useRef(false); - const suppressProjectClickAfterDragRef = useRef(false); - const suppressProjectClickForContextMenuRef = useRef(false); - const [desktopUpdateState, setDesktopUpdateState] = useState(null); - const clearSelection = useThreadSelectionStore((s) => s.clearSelection); - const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); - const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; - const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); - const orderedProjects = useMemo(() => { - return orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - }); - }, [projectOrder, projects]); - - const physicalToLogicalKey = useMemo( - () => buildSidebarPhysicalToLogicalKeyMap(orderedProjects), - [orderedProjects], - ); - - const previousSidebarProjectSnapshotByKeyRef = useRef< - ReadonlyMap - >(new Map()); - const sidebarProjects = useMemo(() => { - const { projectSnapshotByKey, sidebarProjects: nextSidebarProjects } = - buildSidebarProjectSnapshots({ - orderedProjects, - previousProjectSnapshotByKey: previousSidebarProjectSnapshotByKeyRef.current, - primaryEnvironmentId, - savedEnvironmentRegistryById: savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - }); - previousSidebarProjectSnapshotByKeyRef.current = projectSnapshotByKey; - return [...nextSidebarProjects]; - }, [ - orderedProjects, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); - - const sidebarProjectByKey = useMemo( - () => - new Map( - sidebarProjects.map((project) => [project.projectKey, project] as const), - ), - [sidebarProjects], - ); - const sortedProjectKeys = useStore( - useMemo( - () => - createSidebarSortedProjectKeysSelector({ - physicalToLogicalKey, - projects: sidebarProjects, - sortOrder: sidebarProjectSortOrder, - }), - [physicalToLogicalKey, sidebarProjectSortOrder, sidebarProjects], - ), - ); - const focusMostRecentThreadForProject = useCallback( - (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { - const physicalKey = scopedProjectKey( - scopeProjectRef(projectRef.environmentId, projectRef.projectId), - ); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const memberProjectRefs = sidebarProjectByKey.get(logicalKey)?.memberProjectRefs ?? [ - projectRef, - ]; - const latestThread = sortThreadsForSidebar( - selectSidebarThreadsForProjectRefs(useStore.getState(), memberProjectRefs).filter( - (thread) => thread.archivedAt === null, - ), - sidebarThreadSortOrder, - )[0]; - if (!latestThread) return; - - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), - }); - }, - [navigate, physicalToLogicalKey, sidebarProjectByKey, sidebarThreadSortOrder], - ); - - const addProjectFromPath = useCallback( - async (rawCwd: string) => { - const cwd = rawCwd.trim(); - if (!cwd || isAddingProject) return; - const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; - if (!api) return; - - setIsAddingProject(true); - const finishAddingProject = () => { - setIsAddingProject(false); - setNewCwd(""); - setAddProjectError(null); - setAddingProject(false); - }; - - const existing = projects.find((project) => project.cwd === cwd); - if (existing) { - focusMostRecentThreadForProject({ - environmentId: existing.environmentId, - projectId: existing.id, - }); - finishAddingProject(); - return; - } - - const projectId = newProjectId(); - const createdAt = new Date().toISOString(); - const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; - try { - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: newCommandId(), - projectId, - title, - workspaceRoot: cwd, - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - createdAt, - }); - if (activeEnvironmentId !== null) { - await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { - envMode: defaultThreadEnvMode, - }).catch(() => undefined); - } - } catch (error) { - const description = - error instanceof Error ? error.message : "An error occurred while adding the project."; - setIsAddingProject(false); - if (shouldBrowseForProjectImmediately) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description, - }); - } else { - setAddProjectError(description); - } - return; - } - finishAddingProject(); - }, - [ - focusMostRecentThreadForProject, - activeEnvironmentId, - handleNewThread, - isAddingProject, - projects, - shouldBrowseForProjectImmediately, - defaultThreadEnvMode, - ], - ); - - const handleAddProject = () => { - void addProjectFromPath(newCwd); - }; - - const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - - const handlePickFolder = async () => { - const api = readLocalApi(); - if (!api || isPickingFolder) return; - setIsPickingFolder(true); - let pickedPath: string | null = null; - try { - pickedPath = await api.dialogs.pickFolder(); - } catch { - // Ignore picker failures and leave the current thread selection unchanged. - } - if (pickedPath) { - await addProjectFromPath(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { - addProjectInputRef.current?.focus(); - } - setIsPickingFolder(false); - }; - - const handleStartAddProject = () => { - setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { - void handlePickFolder(); - return; - } - setAddingProject((prev) => !prev); - }; - - const navigateToThread = useCallback( - (threadRef: ScopedThreadRef) => { - if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { - clearSelection(); - } - setSelectionAnchor(scopedThreadKey(threadRef)); - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }, - [clearSelection, navigate, setSelectionAnchor], - ); - - const projectDnDSensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 6 }, - }), - ); - const projectCollisionDetection = useCallback((args) => { - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) { - return pointerCollisions; - } - - return closestCorners(args); - }, []); - - const handleProjectDragEnd = useCallback( - (event: DragEndEvent) => { - if (sidebarProjectSortOrder !== "manual") { - dragInProgressRef.current = false; - return; - } - dragInProgressRef.current = false; - const { active, over } = event; - if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); - const overProject = sidebarProjects.find((project) => project.projectKey === over.id); - if (!activeProject || !overProject) return; - reorderProjects(activeProject.projectKey, overProject.projectKey); - }, - [sidebarProjectSortOrder, reorderProjects, sidebarProjects], - ); - - const handleProjectDragStart = useCallback( - (_event: DragStartEvent) => { - if (sidebarProjectSortOrder !== "manual") { - return; - } - dragInProgressRef.current = true; - suppressProjectClickAfterDragRef.current = true; - }, - [sidebarProjectSortOrder], - ); - - const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { - dragInProgressRef.current = false; - }, []); - - const animatedProjectListsRef = useRef(new WeakSet()); - const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { - if (!node || animatedProjectListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedProjectListsRef.current.add(node); - }, []); - - const animatedThreadListsRef = useRef(new WeakSet()); - const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { - if (!node || animatedThreadListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedThreadListsRef.current.add(node); - }, []); - - const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - - useEffect(() => { - return () => { - resetSidebarViewState(); - }; - }, []); - - useEffect(() => { - if (!isElectron) return; - const bridge = window.desktopBridge; - if ( - !bridge || - typeof bridge.getUpdateState !== "function" || - typeof bridge.onUpdateState !== "function" - ) { - return; - } - - let disposed = false; - let receivedSubscriptionUpdate = false; - const unsubscribe = bridge.onUpdateState((nextState) => { - if (disposed) return; - receivedSubscriptionUpdate = true; - setDesktopUpdateState(nextState); - }); - - void bridge - .getUpdateState() - .then((nextState) => { - if (disposed || receivedSubscriptionUpdate) return; - setDesktopUpdateState(nextState); - }) - .catch(() => undefined); - - return () => { - disposed = true; - unsubscribe(); - }; - }, []); - - const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); - const desktopUpdateButtonAction = desktopUpdateState - ? resolveDesktopUpdateButtonAction(desktopUpdateState) - : "none"; - const showArm64IntelBuildWarning = - isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); - const arm64IntelBuildWarningDescription = - desktopUpdateState && showArm64IntelBuildWarning - ? getArm64IntelBuildWarningDescription(desktopUpdateState) - : null; - const handleDesktopUpdateButtonClick = useCallback(() => { - const bridge = window.desktopBridge; - if (!bridge || !desktopUpdateState) return; - if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; - - if (desktopUpdateButtonAction === "download") { - void bridge - .downloadUpdate() - .then((result) => { - if (result.completed) { - toastManager.add({ - type: "success", - title: "Update downloaded", - description: "Restart the app from the update button to install it.", - }); - } - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not download update", - description: actionError, - }); - }) - .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); - }); - return; - } - - if (desktopUpdateButtonAction === "install") { - const confirmed = window.confirm( - getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), - ); - if (!confirmed) return; - void bridge - .installUpdate() - .then((result) => { - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not install update", - description: actionError, - }); - }) - .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); - }); - } - }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - - return ( - <> - - - - - {isOnSettings ? ( - - ) : ( - <> - - - - - - )} - - ); + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const navigate = useNavigate(); + const isOnSettings = useLocation({ select: (loc) => loc.pathname.startsWith("/settings") }); + const settingsPathname = useLocation({ + select: (loc) => (loc.pathname.startsWith("/settings") ? loc.pathname : "/settings"), + }); + const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); + const { updateSettings } = useUpdateSettings(); + const { handleNewThread } = useNewThreadHandler(); + const [addingProject, setAddingProject] = useState(false); + const [newCwd, setNewCwd] = useState(""); + const [isPickingFolder, setIsPickingFolder] = useState(false); + const [isAddingProject, setIsAddingProject] = useState(false); + const [addProjectError, setAddProjectError] = useState(null); + const addProjectInputRef = useRef(null); + const dragInProgressRef = useRef(false); + const suppressProjectClickAfterDragRef = useRef(false); + const suppressProjectClickForContextMenuRef = useRef(false); + const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); + const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; + const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + }); + }, [projectOrder, projects]); + + const physicalToLogicalKey = useMemo( + () => buildSidebarPhysicalToLogicalKeyMap(orderedProjects), + [orderedProjects], + ); + + const previousSidebarProjectSnapshotByKeyRef = useRef< + ReadonlyMap + >(new Map()); + const sidebarProjects = useMemo(() => { + const { projectSnapshotByKey, sidebarProjects: nextSidebarProjects } = + buildSidebarProjectSnapshots({ + orderedProjects, + previousProjectSnapshotByKey: previousSidebarProjectSnapshotByKeyRef.current, + primaryEnvironmentId, + savedEnvironmentRegistryById: savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + }); + previousSidebarProjectSnapshotByKeyRef.current = projectSnapshotByKey; + return [...nextSidebarProjects]; + }, [ + orderedProjects, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + + const sidebarProjectByKey = useMemo( + () => + new Map( + sidebarProjects.map((project) => [project.projectKey, project] as const), + ), + [sidebarProjects], + ); + const sortedProjectKeys = useStore( + useMemo( + () => + createSidebarSortedProjectKeysSelector({ + physicalToLogicalKey, + projects: sidebarProjects, + sortOrder: sidebarProjectSortOrder, + }), + [physicalToLogicalKey, sidebarProjectSortOrder, sidebarProjects], + ), + ); + const focusMostRecentThreadForProject = useCallback( + (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { + const physicalKey = scopedProjectKey( + scopeProjectRef(projectRef.environmentId, projectRef.projectId), + ); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const memberProjectRefs = sidebarProjectByKey.get(logicalKey)?.memberProjectRefs ?? [ + projectRef, + ]; + const latestThread = sortThreadsForSidebar( + selectSidebarThreadsForProjectRefs(useStore.getState(), memberProjectRefs).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + )[0]; + if (!latestThread) return; + + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), + }); + }, + [navigate, physicalToLogicalKey, sidebarProjectByKey, sidebarThreadSortOrder], + ); + + const addProjectFromPath = useCallback( + async (rawCwd: string) => { + const cwd = rawCwd.trim(); + if (!cwd || isAddingProject) return; + const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; + if (!api) return; + + setIsAddingProject(true); + const finishAddingProject = () => { + setIsAddingProject(false); + setNewCwd(""); + setAddProjectError(null); + setAddingProject(false); + }; + + const existing = projects.find((project) => project.cwd === cwd); + if (existing) { + focusMostRecentThreadForProject({ + environmentId: existing.environmentId, + projectId: existing.id, + }); + finishAddingProject(); + return; + } + + const projectId = newProjectId(); + const createdAt = new Date().toISOString(); + const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; + try { + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title, + workspaceRoot: cwd, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + createdAt, + }); + if (activeEnvironmentId !== null) { + await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { + envMode: defaultThreadEnvMode, + }).catch(() => undefined); + } + } catch (error) { + const description = + error instanceof Error ? error.message : "An error occurred while adding the project."; + setIsAddingProject(false); + if (shouldBrowseForProjectImmediately) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description, + }); + } else { + setAddProjectError(description); + } + return; + } + finishAddingProject(); + }, + [ + focusMostRecentThreadForProject, + activeEnvironmentId, + handleNewThread, + isAddingProject, + projects, + shouldBrowseForProjectImmediately, + defaultThreadEnvMode, + ], + ); + + const handleAddProject = () => { + void addProjectFromPath(newCwd); + }; + + const canAddProject = newCwd.trim().length > 0 && !isAddingProject; + + const handlePickFolder = async () => { + const api = readLocalApi(); + if (!api || isPickingFolder) return; + setIsPickingFolder(true); + let pickedPath: string | null = null; + try { + pickedPath = await api.dialogs.pickFolder(); + } catch { + // Ignore picker failures and leave the current thread selection unchanged. + } + if (pickedPath) { + await addProjectFromPath(pickedPath); + } else if (!shouldBrowseForProjectImmediately) { + addProjectInputRef.current?.focus(); + } + setIsPickingFolder(false); + }; + + const handleStartAddProject = () => { + setAddProjectError(null); + if (shouldBrowseForProjectImmediately) { + void handlePickFolder(); + return; + } + setAddingProject((prev) => !prev); + }; + + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, navigate, setSelectionAnchor], + ); + + const projectDnDSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + ); + const projectCollisionDetection = useCallback((args) => { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + + return closestCorners(args); + }, []); + + const handleProjectDragEnd = useCallback( + (event: DragEndEvent) => { + if (sidebarProjectSortOrder !== "manual") { + dragInProgressRef.current = false; + return; + } + dragInProgressRef.current = false; + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); + const overProject = sidebarProjects.find((project) => project.projectKey === over.id); + if (!activeProject || !overProject) return; + reorderProjects(activeProject.projectKey, overProject.projectKey); + }, + [sidebarProjectSortOrder, reorderProjects, sidebarProjects], + ); + + const handleProjectDragStart = useCallback( + (_event: DragStartEvent) => { + if (sidebarProjectSortOrder !== "manual") { + return; + } + dragInProgressRef.current = true; + suppressProjectClickAfterDragRef.current = true; + }, + [sidebarProjectSortOrder], + ); + + const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { + dragInProgressRef.current = false; + }, []); + + const animatedProjectListsRef = useRef(new WeakSet()); + const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedProjectListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedProjectListsRef.current.add(node); + }, []); + + const animatedThreadListsRef = useRef(new WeakSet()); + const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedThreadListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedThreadListsRef.current.add(node); + }, []); + + const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + + useEffect(() => { + return () => { + resetSidebarViewState(); + }; + }, []); + + useEffect(() => { + if (!isElectron) return; + const bridge = window.desktopBridge; + if ( + !bridge || + typeof bridge.getUpdateState !== "function" || + typeof bridge.onUpdateState !== "function" + ) { + return; + } + + let disposed = false; + let receivedSubscriptionUpdate = false; + const unsubscribe = bridge.onUpdateState((nextState) => { + if (disposed) return; + receivedSubscriptionUpdate = true; + setDesktopUpdateState(nextState); + }); + + void bridge + .getUpdateState() + .then((nextState) => { + if (disposed || receivedSubscriptionUpdate) return; + setDesktopUpdateState(nextState); + }) + .catch(() => undefined); + + return () => { + disposed = true; + unsubscribe(); + }; + }, []); + + const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); + const desktopUpdateButtonAction = desktopUpdateState + ? resolveDesktopUpdateButtonAction(desktopUpdateState) + : "none"; + const showArm64IntelBuildWarning = + isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); + const arm64IntelBuildWarningDescription = + desktopUpdateState && showArm64IntelBuildWarning + ? getArm64IntelBuildWarningDescription(desktopUpdateState) + : null; + const handleDesktopUpdateButtonClick = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge || !desktopUpdateState) return; + if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; + + if (desktopUpdateButtonAction === "download") { + void bridge + .downloadUpdate() + .then((result) => { + if (result.completed) { + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not download update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + return; + } + + if (desktopUpdateButtonAction === "install") { + const confirmed = window.confirm( + getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), + ); + if (!confirmed) return; + void bridge + .installUpdate() + .then((result) => { + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not install update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + } + }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); + + return ( + <> + + + + + {isOnSettings ? ( + + ) : ( + <> + + + + + + )} + + ); } diff --git a/apps/web/src/components/sidebar/sidebarControllers.tsx b/apps/web/src/components/sidebar/sidebarControllers.tsx index c17bb62504..4713193c31 100644 --- a/apps/web/src/components/sidebar/sidebarControllers.tsx +++ b/apps/web/src/components/sidebar/sidebarControllers.tsx @@ -7,28 +7,28 @@ import { useShallow } from "zustand/react/shallow"; import { isTerminalFocused } from "../../lib/terminalFocus"; import { resolveThreadRouteRef } from "../../threadRoutes"; import { - resolveShortcutCommand, - shortcutLabelForCommand, - shouldShowThreadJumpHints, - threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHints, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, } from "../../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../../terminalStateStore"; import { useUiStateStore } from "../../uiStateStore"; import { - resolveAdjacentThreadId, - shouldClearThreadSelectionOnMouseDown, - useThreadJumpHintVisibility, + resolveAdjacentThreadId, + shouldClearThreadSelectionOnMouseDown, + useThreadJumpHintVisibility, } from "../Sidebar.logic"; import { - createSidebarActiveRouteProjectKeySelectorByRef, - createSidebarSortedThreadKeysByLogicalProjectSelector, + createSidebarActiveRouteProjectKeySelectorByRef, + createSidebarSortedThreadKeysByLogicalProjectSelector, } from "./sidebarSelectors"; import type { LogicalProjectKey } from "../../logicalProject"; import { - setSidebarKeyboardState, - useSidebarExpandedThreadListsByProject, + setSidebarKeyboardState, + useSidebarExpandedThreadListsByProject, } from "./sidebarViewStore"; import { useServerKeybindings } from "../../rpc/serverState"; import { useStore } from "../../store"; @@ -38,386 +38,386 @@ import { THREAD_PREVIEW_LIMIT } from "./sidebarConstants"; const EMPTY_THREAD_JUMP_LABELS = new Map(); function buildThreadJumpLabelMap(input: { - keybindings: ReturnType; - platform: string; - terminalOpen: boolean; - threadJumpCommandByKey: ReadonlyMap< - string, - NonNullable> - >; + keybindings: ReturnType; + platform: string; + terminalOpen: boolean; + threadJumpCommandByKey: ReadonlyMap< + string, + NonNullable> + >; }): ReadonlyMap { - if (input.threadJumpCommandByKey.size === 0) { - return EMPTY_THREAD_JUMP_LABELS; - } + if (input.threadJumpCommandByKey.size === 0) { + return EMPTY_THREAD_JUMP_LABELS; + } - const shortcutLabelOptions = { - platform: input.platform, - context: { - terminalFocus: false, - terminalOpen: input.terminalOpen, - }, - } as const; - const mapping = new Map(); - for (const [threadKey, command] of input.threadJumpCommandByKey) { - const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); - if (label) { - mapping.set(threadKey, label); - } - } - return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; + const shortcutLabelOptions = { + platform: input.platform, + context: { + terminalFocus: false, + terminalOpen: input.terminalOpen, + }, + } as const; + const mapping = new Map(); + for (const [threadKey, command] of input.threadJumpCommandByKey) { + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(threadKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } function useSidebarKeyboardController(input: { - physicalToLogicalKey: ReadonlyMap; - sortedProjectKeys: readonly LogicalProjectKey[]; - expandedThreadListsByProject: ReadonlySet; - routeThreadRef: ScopedThreadRef | null; - routeThreadKey: string | null; - platform: string; - keybindings: ReturnType; - navigateToThread: (threadRef: ScopedThreadRef) => void; - threadSortOrder: SidebarThreadSortOrder; + physicalToLogicalKey: ReadonlyMap; + sortedProjectKeys: readonly LogicalProjectKey[]; + expandedThreadListsByProject: ReadonlySet; + routeThreadRef: ScopedThreadRef | null; + routeThreadKey: string | null; + platform: string; + keybindings: ReturnType; + navigateToThread: (threadRef: ScopedThreadRef) => void; + threadSortOrder: SidebarThreadSortOrder; }) { - const { - physicalToLogicalKey, - sortedProjectKeys, - expandedThreadListsByProject, - routeThreadRef, - routeThreadKey, - platform, - keybindings, - navigateToThread, - threadSortOrder, - } = input; - const projectExpandedStates = useUiStateStore( - useShallow((store) => - sortedProjectKeys.map((projectKey) => store.projectExpandedById[projectKey] ?? true), - ), - ); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const sortedThreadKeysByLogicalProject = useStore( - useMemo( - () => - createSidebarSortedThreadKeysByLogicalProjectSelector({ - physicalToLogicalKey, - threadSortOrder, - }), - [physicalToLogicalKey, threadSortOrder], - ), - ); - const visibleSidebarThreadKeys = useMemo( - () => - sortedProjectKeys.flatMap((projectKey, index) => { - const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; - const projectExpanded = projectExpandedStates[index] ?? true; - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } + const { + physicalToLogicalKey, + sortedProjectKeys, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + threadSortOrder, + } = input; + const projectExpandedStates = useUiStateStore( + useShallow((store) => + sortedProjectKeys.map((projectKey) => store.projectExpandedById[projectKey] ?? true), + ), + ); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const sortedThreadKeysByLogicalProject = useStore( + useMemo( + () => + createSidebarSortedThreadKeysByLogicalProjectSelector({ + physicalToLogicalKey, + threadSortOrder, + }), + [physicalToLogicalKey, threadSortOrder], + ), + ); + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjectKeys.flatMap((projectKey, index) => { + const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; + const projectExpanded = projectExpandedStates[index] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } - const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); - const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; - const previewThreadKeys = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreadKeys - : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); - return pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreadKeys; - }), - [ - expandedThreadListsByProject, - projectExpandedStates, - routeThreadKey, - sortedProjectKeys, - sortedThreadKeysByLogicalProject, - ], - ); - const threadJumpCommandByKey = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - return mapping; - } - mapping.set(threadKey, jumpCommand); - } + const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); + const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; + const previewThreadKeys = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreadKeys + : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); + return pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreadKeys; + }), + [ + expandedThreadListsByProject, + projectExpandedStates, + routeThreadKey, + sortedProjectKeys, + sortedThreadKeysByLogicalProject, + ], + ); + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(threadKey, jumpCommand); + } - return mapping; - }, [visibleSidebarThreadKeys]); - const threadJumpThreadKeys = useMemo( - () => [...threadJumpCommandByKey.keys()], - [threadJumpCommandByKey], - ); - const getCurrentSidebarShortcutContext = useCallback( - () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - }), - [routeThreadRef], - ); - const threadJumpLabelByKey = useMemo( - () => - showThreadJumpHints - ? buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: getCurrentSidebarShortcutContext().terminalOpen, - threadJumpCommandByKey, - }) - : EMPTY_THREAD_JUMP_LABELS, - [ - getCurrentSidebarShortcutContext, - keybindings, - platform, - showThreadJumpHints, - threadJumpCommandByKey, - ], - ); - const threadJumpLabelsRef = useRef>(threadJumpLabelByKey); - threadJumpLabelsRef.current = threadJumpLabelByKey; - const showThreadJumpHintsRef = useRef(showThreadJumpHints); - showThreadJumpHintsRef.current = showThreadJumpHints; - const latestKeyboardStateRef = useRef({ - keybindings, - navigateToThread, - platform, - routeThreadKey, - routeThreadRef, - threadJumpThreadKeys, - visibleSidebarThreadKeys, - }); - latestKeyboardStateRef.current = { - keybindings, - navigateToThread, - platform, - routeThreadKey, - routeThreadRef, - threadJumpThreadKeys, - visibleSidebarThreadKeys, - }; - const updateThreadJumpHintsVisibilityRef = useRef(updateThreadJumpHintsVisibility); - updateThreadJumpHintsVisibilityRef.current = updateThreadJumpHintsVisibility; + return mapping; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }), + [routeThreadRef], + ); + const threadJumpLabelByKey = useMemo( + () => + showThreadJumpHints + ? buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: getCurrentSidebarShortcutContext().terminalOpen, + threadJumpCommandByKey, + }) + : EMPTY_THREAD_JUMP_LABELS, + [ + getCurrentSidebarShortcutContext, + keybindings, + platform, + showThreadJumpHints, + threadJumpCommandByKey, + ], + ); + const threadJumpLabelsRef = useRef>(threadJumpLabelByKey); + threadJumpLabelsRef.current = threadJumpLabelByKey; + const showThreadJumpHintsRef = useRef(showThreadJumpHints); + showThreadJumpHintsRef.current = showThreadJumpHints; + const latestKeyboardStateRef = useRef({ + keybindings, + navigateToThread, + platform, + routeThreadKey, + routeThreadRef, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + }); + latestKeyboardStateRef.current = { + keybindings, + navigateToThread, + platform, + routeThreadKey, + routeThreadRef, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + }; + const updateThreadJumpHintsVisibilityRef = useRef(updateThreadJumpHintsVisibility); + updateThreadJumpHintsVisibilityRef.current = updateThreadJumpHintsVisibility; - useEffect(() => { - const clearThreadJumpHints = () => { - updateThreadJumpHintsVisibilityRef.current(false); - }; - const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - event.key !== "Meta" && - event.key !== "Control" && - event.key !== "Alt" && - event.key !== "Shift" && - !showThreadJumpHintsRef.current && - threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; - const getCurrentSidebarShortcutContext = () => { - const { routeThreadRef } = latestKeyboardStateRef.current; - return { - terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - }; - }; + useEffect(() => { + const clearThreadJumpHints = () => { + updateThreadJumpHintsVisibilityRef.current(false); + }; + const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + event.key !== "Meta" && + event.key !== "Control" && + event.key !== "Alt" && + event.key !== "Shift" && + !showThreadJumpHintsRef.current && + threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + const getCurrentSidebarShortcutContext = () => { + const { routeThreadRef } = latestKeyboardStateRef.current; + return { + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }; + }; - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } - const { - keybindings, - navigateToThread, - platform, - routeThreadKey, - threadJumpThreadKeys, - visibleSidebarThreadKeys, - } = latestKeyboardStateRef.current; - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - if ( - showThreadJumpHintsRef.current || - threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS - ) { - clearThreadJumpHints(); - } - } else { - updateThreadJumpHintsVisibilityRef.current(true); - } + const { + keybindings, + navigateToThread, + platform, + routeThreadKey, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + } = latestKeyboardStateRef.current; + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + if ( + showThreadJumpHintsRef.current || + threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS + ) { + clearThreadJumpHints(); + } + } else { + updateThreadJumpHintsVisibilityRef.current(true); + } - if (event.defaultPrevented || event.repeat) { - return; - } + if (event.defaultPrevented || event.repeat) { + return; + } - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, - }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: visibleSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = parseScopedThreadKey(targetThreadKey); - if (!targetThread) { - return; - } + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: shortcutContext, + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: visibleSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }); + if (!targetThreadKey) { + return; + } + const targetThread = parseScopedThreadKey(targetThreadKey); + if (!targetThread) { + return; + } - event.preventDefault(); - event.stopPropagation(); - navigateToThread(targetThread); - return; - } + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThread); + return; + } - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = parseScopedThreadKey(targetThreadKey); - if (!targetThread) { - return; - } + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = parseScopedThreadKey(targetThreadKey); + if (!targetThread) { + return; + } - event.preventDefault(); - event.stopPropagation(); - navigateToThread(targetThread); - }; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThread); + }; - const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } + const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } - const { keybindings, platform } = latestKeyboardStateRef.current; - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - clearThreadJumpHints(); - return; - } - updateThreadJumpHintsVisibilityRef.current(true); - }; + const { keybindings, platform } = latestKeyboardStateRef.current; + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + clearThreadJumpHints(); + return; + } + updateThreadJumpHintsVisibilityRef.current(true); + }; - const onWindowBlur = () => { - clearThreadJumpHints(); - }; + const onWindowBlur = () => { + clearThreadJumpHints(); + }; - window.addEventListener("keydown", onWindowKeyDown); - window.addEventListener("keyup", onWindowKeyUp); - window.addEventListener("blur", onWindowBlur); + window.addEventListener("keydown", onWindowKeyDown); + window.addEventListener("keyup", onWindowKeyUp); + window.addEventListener("blur", onWindowBlur); - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); - }; - }, []); + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + window.removeEventListener("keyup", onWindowKeyUp); + window.removeEventListener("blur", onWindowBlur); + }; + }, []); - return threadJumpLabelByKey; + return threadJumpLabelByKey; } export const SidebarSelectionController = memo(function SidebarSelectionController() { - const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); - const clearSelection = useThreadSelectionStore((state) => state.clearSelection); - const selectedThreadCountRef = useRef(selectedThreadCount); - selectedThreadCountRef.current = selectedThreadCount; - const clearSelectionRef = useRef(clearSelection); - clearSelectionRef.current = clearSelection; + const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const selectedThreadCountRef = useRef(selectedThreadCount); + selectedThreadCountRef.current = selectedThreadCount; + const clearSelectionRef = useRef(clearSelection); + clearSelectionRef.current = clearSelection; - useEffect(() => { - const onMouseDown = (event: globalThis.MouseEvent) => { - if (selectedThreadCountRef.current === 0) { - return; - } - const target = event.target instanceof Element ? event.target : null; - if (!shouldClearThreadSelectionOnMouseDown(target)) { - return; - } - clearSelectionRef.current(); - }; + useEffect(() => { + const onMouseDown = (event: globalThis.MouseEvent) => { + if (selectedThreadCountRef.current === 0) { + return; + } + const target = event.target instanceof Element ? event.target : null; + if (!shouldClearThreadSelectionOnMouseDown(target)) { + return; + } + clearSelectionRef.current(); + }; - window.addEventListener("mousedown", onMouseDown); - return () => { - window.removeEventListener("mousedown", onMouseDown); - }; - }, []); + window.addEventListener("mousedown", onMouseDown); + return () => { + window.removeEventListener("mousedown", onMouseDown); + }; + }, []); - return null; + return null; }); export const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { - navigateToThread: (threadRef: ScopedThreadRef) => void; - physicalToLogicalKey: ReadonlyMap; - sortedProjectKeys: readonly LogicalProjectKey[]; - sidebarThreadSortOrder: SidebarThreadSortOrder; + navigateToThread: (threadRef: ScopedThreadRef) => void; + physicalToLogicalKey: ReadonlyMap; + sortedProjectKeys: readonly LogicalProjectKey[]; + sidebarThreadSortOrder: SidebarThreadSortOrder; }) { - const { navigateToThread, physicalToLogicalKey, sortedProjectKeys, sidebarThreadSortOrder } = - props; - const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); - const routeThreadRef = useParams({ - strict: false, - select: (params) => resolveThreadRouteRef(params), - }); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const activeRouteProjectKey = useStore( - useMemo( - () => createSidebarActiveRouteProjectKeySelectorByRef(routeThreadRef, physicalToLogicalKey), - [physicalToLogicalKey, routeThreadRef], - ), - ); - const keybindings = useServerKeybindings(); - const platform = navigator.platform; - const threadJumpLabelByKey = useSidebarKeyboardController({ - physicalToLogicalKey, - sortedProjectKeys, - expandedThreadListsByProject, - routeThreadRef, - routeThreadKey, - platform, - keybindings, - navigateToThread, - threadSortOrder: sidebarThreadSortOrder, - }); + const { navigateToThread, physicalToLogicalKey, sortedProjectKeys, sidebarThreadSortOrder } = + props; + const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const activeRouteProjectKey = useStore( + useMemo( + () => createSidebarActiveRouteProjectKeySelectorByRef(routeThreadRef, physicalToLogicalKey), + [physicalToLogicalKey, routeThreadRef], + ), + ); + const keybindings = useServerKeybindings(); + const platform = navigator.platform; + const threadJumpLabelByKey = useSidebarKeyboardController({ + physicalToLogicalKey, + sortedProjectKeys, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + threadSortOrder: sidebarThreadSortOrder, + }); - useEffect(() => { - setSidebarKeyboardState({ - activeRouteProjectKey, - activeRouteThreadKey: routeThreadKey, - threadJumpLabelByKey, - }); - }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); + useEffect(() => { + setSidebarKeyboardState({ + activeRouteProjectKey, + activeRouteThreadKey: routeThreadKey, + threadJumpLabelByKey, + }); + }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); - return null; + return null; }); From 6f1805a7808a778da00f6204c24674bb47b255e6 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:37:26 +0100 Subject: [PATCH 9/9] fix typecheck :) --- apps/web/src/components/sidebar/sidebarControllers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/sidebarControllers.tsx b/apps/web/src/components/sidebar/sidebarControllers.tsx index 4713193c31..9d83b9f94b 100644 --- a/apps/web/src/components/sidebar/sidebarControllers.tsx +++ b/apps/web/src/components/sidebar/sidebarControllers.tsx @@ -361,7 +361,7 @@ export const SidebarSelectionController = memo(function SidebarSelectionControll if (selectedThreadCountRef.current === 0) { return; } - const target = event.target instanceof Element ? event.target : null; + const target = event.target instanceof HTMLElement ? event.target : null; if (!shouldClearThreadSelectionOnMouseDown(target)) { return; }