diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ed151c6da8..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 = 100; +export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 75; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; @@ -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 c5725c6d0d..478c94b6d8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -35,19 +35,19 @@ import { type DesktopUpdateState, type EnvironmentId, ProjectId, - type ScopedProjectRef, type ScopedThreadRef, type ThreadEnvMode, ThreadId, type GitStatusResult, } from "@t3tools/contracts"; import { + parseScopedThreadKey, scopedProjectKey, scopedThreadKey, 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, @@ -55,38 +55,26 @@ 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, selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRef, + selectSidebarThreadSummaryByRef, selectSidebarThreadsForProjectRefs, - selectSidebarThreadsAcrossEnvironments, + selectThreadIdsByProjectRef, selectThreadByRef, useStore, } 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"; @@ -120,7 +108,6 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { - resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, @@ -128,24 +115,47 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, - shouldClearThreadSelectionOnMouseDown, - sortProjectsForSidebar, sortThreadsForSidebar, - useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; +import { + createSidebarSortedProjectKeysSelector, + createSidebarProjectRenderStateSelector, + createSidebarProjectThreadStatusInputsSelector, + createSidebarThreadMetaSnapshotSelectorByRef, + createSidebarThreadRowSnapshotSelectorByRef, + createSidebarThreadStatusInputSelectorByRef, + type ProjectThreadStatusInput, +} from "./sidebar/sidebarSelectors"; +import { THREAD_PREVIEW_LIMIT } from "./sidebar/sidebarConstants"; +import { + SidebarKeyboardController, + SidebarSelectionController, +} from "./sidebar/sidebarControllers"; +import { + collapseSidebarProjectThreadList, + expandSidebarProjectThreadList, + resetSidebarViewState, + useSidebarIsActiveThread, + useSidebarProjectActiveRouteThreadKey, + useSidebarProjectThreadListExpanded, + useSidebarThreadJumpLabel, +} 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"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { deriveLogicalProjectKey } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { Project, SidebarThreadSummary } from "../types"; -const THREAD_PREVIEW_LIMIT = 6; +import type { LogicalProjectKey } from "../logicalProject"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -159,65 +169,7 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; -const EMPTY_THREAD_JUMP_LABELS = new Map(); - -function threadJumpLabelMapsEqual( - left: ReadonlyMap, - right: ReadonlyMap, -): boolean { - if (left === right) { - return true; - } - if (left.size !== right.size) { - return false; - } - for (const [key, value] of left) { - if (right.get(key) !== value) { - return false; - } - } - return true; -} - -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[]; -}; interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -233,6 +185,14 @@ interface PrStatusIndicator { type ThreadPr = GitStatusResult["pr"]; +function useSidebarThreadStatusInput( + threadRef: ScopedThreadRef | null, +): ProjectThreadStatusInput | undefined { + return useStore( + useMemo(() => createSidebarThreadStatusInputSelectorByRef(threadRef), [threadRef]), + ); +} + function ThreadStatusLabel({ status, compact = false, @@ -325,77 +285,299 @@ function resolveThreadPr( return gitStatus.pr ?? null; } -interface SidebarThreadRowProps { - thread: SidebarThreadSummary; - projectCwd: string | null; - orderedProjectThreadKeys: readonly string[]; - isActive: boolean; - jumpLabel: string | null; +const SidebarThreadMetaCluster = memo(function SidebarThreadMetaCluster(props: { 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 SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + 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 { - 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, + isHighlighted, + isRemoteThread, + stopPropagationOnPointerDown, + threadEnvironmentLabel, + threadId, + threadKey, + threadRef, + threadTitle, } = props; - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const threadKey = scopedThreadKey(threadRef); + 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; +}) { + 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; + }, +); + +interface SidebarThreadRowProps { + 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 runningTerminalIds = useTerminalStateStore( - (state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, - ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; @@ -419,32 +601,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,104 +627,351 @@ 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, - ], - ); - 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], + [clearSelection, router, setSelectionAnchor], ); - const handleRenameInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - renamingCommittedRef.current = true; + 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, 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 +983,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 +1046,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)} - - )} - - +
@@ -755,89 +1088,30 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }); interface SidebarProjectThreadListProps { - projectKey: string; + project: SidebarProjectSnapshot; 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( props: SidebarProjectThreadListProps, ) { const { - projectKey, + project, 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 { + 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 ( + <> + + + ); @@ -2005,6 +1961,8 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { }); interface SidebarProjectsContentProps { + sortedProjectKeys: readonly LogicalProjectKey[]; + sidebarProjectByKey: ReadonlyMap; showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; desktopUpdateButtonAction: "download" | "install" | "none"; @@ -2033,18 +1991,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; @@ -2056,6 +2003,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( props: SidebarProjectsContentProps, ) { const { + sortedProjectKeys, + sidebarProjectByKey, showArm64IntelBuildWarning, arm64IntelBuildWarningDescription, desktopUpdateButtonAction, @@ -2084,25 +2033,13 @@ 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 handleProjectSortOrderChange = useCallback( (sortOrder: SidebarProjectSortOrder) => { updateSettings({ sidebarProjectSortOrder: sortOrder }); @@ -2252,65 +2189,59 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( > project.projectKey)} + items={[...sortedProjectKeys]} strategy={verticalListSortingStrategy} > - {sortedProjects.map((project) => ( - - {(dragHandleProps) => ( - - )} - - ))} + {sortedProjectKeys.map((projectKey) => + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; + } + return ( + + {(dragHandleProps) => ( + + )} + + ); + })(), + )} ) : ( - {sortedProjects.map((project) => ( - + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; } - newThreadShortcutLabel={newThreadShortcutLabel} - handleNewThread={handleNewThread} - archiveThread={archiveThread} - deleteThread={deleteThread} - threadJumpLabelByKey={threadJumpLabelByKey} - attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} - expandThreadListForProject={expandThreadListForProject} - collapseThreadListForProject={collapseThreadListForProject} - dragInProgressRef={dragInProgressRef} - suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef} - suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} - isManualProjectSorting={isManualProjectSorting} - dragHandleProps={null} - /> - ))} + return ( + + ); + })(), + )} )} @@ -2326,45 +2257,32 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); const activeEnvironmentId = useStore((store) => 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 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 { archiveThread, deleteThread } = useThreadActions(); - const routeThreadRef = useParams({ - strict: false, - select: (params) => resolveThreadRouteRef(params), - }); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const keybindings = useServerKeybindings(); 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); 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); - const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -2378,81 +2296,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 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 snapshot: 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; + const { projectSnapshotByKey, sidebarProjects: nextSidebarProjects } = + buildSidebarProjectSnapshots({ + orderedProjects, + previousProjectSnapshotByKey: previousSidebarProjectSnapshotByKeyRef.current, + primaryEnvironmentId, + savedEnvironmentRegistryById: savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + }); + previousSidebarProjectSnapshotByKeyRef.current = projectSnapshotByKey; + return [...nextSidebarProjects]; }, [ orderedProjects, primaryEnvironmentId, @@ -2461,82 +2323,36 @@ export default function Sidebar() { ]); 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, - ), + new Map( + sidebarProjects.map((project) => [project.projectKey, project] 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]); - } - } - return next; - }, [sidebarThreads, physicalToLogicalKey]); - const getCurrentSidebarShortcutContext = useCallback( - () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - }), - [routeThreadRef], + [sidebarProjects], ); - const newThreadShortcutLabelOptions = useMemo( - () => ({ - platform, - context: { - terminalFocus: false, - terminalOpen: false, - }, - }), - [platform], + const sortedProjectKeys = useStore( + useMemo( + () => + createSidebarSortedProjectKeysSelector({ + physicalToLogicalKey, + projects: sidebarProjects, + sortOrder: sidebarProjectSortOrder, + }), + [physicalToLogicalKey, sidebarProjectSortOrder, 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 +2362,7 @@ export default function Sidebar() { params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), }); }, - [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], + [navigate, physicalToLogicalKey, sidebarProjectByKey, sidebarThreadSortOrder], ); const addProjectFromPath = useCallback( @@ -2736,268 +2552,13 @@ 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); - - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); - }; - }, [ - getCurrentSidebarShortcutContext, - keybindings, - navigateToThread, - orderedSidebarThreadKeys, - platform, - routeThreadKey, - sidebarThreadByKey, - threadJumpCommandByKey, - threadJumpThreadKeys, - updateThreadJumpHintsVisibility, - ]); 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); + resetSidebarViewState(); }; - }, [clearSelection, selectedThreadCount]); + }, []); useEffect(() => { if (!isElectron) return; @@ -3104,33 +2665,24 @@ 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 ? ( - + ) : ( <> (); + +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: { + 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 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; + + 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 { + 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; + } + + 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; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = parseScopedThreadKey(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThread); + }; + + 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 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); + }; + }, []); + + 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 SidebarKeyboardController = memo(function SidebarKeyboardController(props: { + 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, + }); + + useEffect(() => { + setSidebarKeyboardState({ + activeRouteProjectKey, + activeRouteThreadKey: routeThreadKey, + threadJumpLabelByKey, + }); + }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); + + return null; +}); diff --git a/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts new file mode 100644 index 0000000000..4e2f57eae5 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts @@ -0,0 +1,214 @@ +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, 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; +} | null; + +type SavedEnvironmentRuntimeEntry = { + descriptor?: { + label?: string | null; + } | 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 { + 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 new file mode 100644 index 0000000000..da25d42344 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -0,0 +1,643 @@ +import type { EnvironmentId, ProjectId, ScopedProjectRef, ThreadId } from "@t3tools/contracts"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; +import { + sortProjectsForSidebar, + 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 SidebarProjectOrderingThreadSnapshot { + id: ThreadId; + environmentId: EnvironmentId; + projectId: LogicalProjectKey; + createdAt: string; + archivedAt: string | null; + updatedAt?: string | undefined; + latestUserMessageAt: string | null; +} + +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(); +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]); +} + +export interface ProjectThreadStatusInput { + threadKey: string; + hasActionableProposedPlan: boolean; + hasPendingApprovals: boolean; + hasPendingUserInput: boolean; + interactionMode: SidebarThreadSummary["interactionMode"]; + latestTurn: ThreadStatusLatestTurnSnapshot | null; + session: ThreadStatusSessionSnapshot | null; +} + +export interface SidebarThreadRowSnapshot { + id: ThreadId; + environmentId: EnvironmentId; + projectId: ProjectId; + title: string; + branch: string | null; + worktreePath: string | null; +} + +export interface SidebarThreadMetaSnapshot { + activityTimestamp: string; + isRunning: boolean; +} + +interface ProjectThreadRenderEntry { + threadKey: string; + id: ThreadId; + environmentId: EnvironmentId; + projectId: LogicalProjectKey; + 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[], + physicalToLogicalKey?: ReadonlyMap, +): 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: + physicalToLogicalKey?.get( + scopedProjectKey(scopeProjectRef(summary.environmentId, summary.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(summary.environmentId, 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 + ? { + 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, + }); + } + } + 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?.turnId === right.latestTurn?.turnId && + 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 + ); +} + +function includeUpdatedSortFields( + sortOrder: SidebarProjectSortOrder | SidebarThreadSortOrder, +): boolean { + return sortOrder === "updated_at"; +} + +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 (input.sortOrder === "manual") { + previousEntries = new Map(); + previousResult = EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS; + return previousResult; + } + + const watchUpdatedFields = includeUpdatedSortFields(input.sortOrder); + const nextEntries = new Map(); + const nextResult: SidebarProjectOrderingThreadSnapshot[] = []; + 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 || 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 === logicalProjectKey && + previousEntry.createdAt === summary.createdAt && + previousEntry.archivedAt === summary.archivedAt && + (!watchUpdatedFields || + (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: SidebarProjectOrderingThreadSnapshot = { + id: summary.id, + environmentId: summary.environmentId, + projectId: logicalProjectKey, + 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_PROJECT_ORDERING_THREAD_SNAPSHOTS : nextResult; + return previousResult; + }; +} + +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; +}): (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; + }; +} + +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 + ? { + 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)) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +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; + memberProjectRefs: readonly ScopedProjectRef[]; + physicalToLogicalKey?: ReadonlyMap; + projectExpanded: boolean; + previewLimit: number; + threadSortOrder: SidebarThreadSortOrder; +}): (state: AppState) => SidebarProjectRenderStateSnapshot { + let previousResult = EMPTY_PROJECT_RENDER_STATE; + + return (state) => { + const visibleProjectThreads = sortThreadsForSidebar( + collectProjectThreadEntries( + state, + input.memberProjectRefs, + input.physicalToLogicalKey, + ).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; + }; +} diff --git a/apps/web/src/components/sidebar/sidebarViewStore.ts b/apps/web/src/components/sidebar/sidebarViewStore.ts new file mode 100644 index 0000000000..9e3d96b64b --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarViewStore.ts @@ -0,0 +1,126 @@ +import { useCallback } from "react"; +import { useStore as useZustandStore } from "zustand"; +import { createStore } from "zustand/vanilla"; +import type { LogicalProjectKey } from "../../logicalProject"; + +interface SidebarTransientState { + activeRouteThreadKey: string | null; + activeRouteProjectKey: LogicalProjectKey | null; + threadJumpLabelByKey: ReadonlyMap; + expandedThreadListsByProject: ReadonlySet; +} + +const EMPTY_THREAD_JUMP_LABELS = new Map(); +const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); + +const sidebarViewStore = createStore(() => ({ + activeRouteThreadKey: null, + activeRouteProjectKey: null, + threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, + expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, +})); + +export function resetSidebarViewState(): void { + sidebarViewStore.setState({ + activeRouteThreadKey: null, + activeRouteProjectKey: null, + threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, + expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, + }); +} + +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 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); }