From 2135bad0971dfe2400c61dc37586c99719a52269 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:18:36 +0100 Subject: [PATCH 01/21] Refactor sidebar rerender boundaries --- apps/web/src/components/Sidebar.logic.ts | 2 +- apps/web/src/components/Sidebar.tsx | 3056 +++++++++-------- .../components/sidebar/sidebarSelectors.ts | 390 +++ 3 files changed, 2025 insertions(+), 1423 deletions(-) create mode 100644 apps/web/src/components/sidebar/sidebarSelectors.ts diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8c742cbe9b..e0fbb6439f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -11,7 +11,7 @@ import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; -export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; +export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 0; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 03ae979017..1ee381a1d7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -15,6 +15,8 @@ import { import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; +import { useStore as useZustandStore } from "zustand"; +import { createStore } from "zustand/vanilla"; import { useShallow } from "zustand/react/shallow"; import { DndContext, @@ -43,6 +45,7 @@ import { type GitStatusResult, } from "@t3tools/contracts"; import { + parseScopedThreadKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, @@ -61,9 +64,9 @@ import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../l import { selectProjectByRef, selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRef, + selectSidebarThreadSummaryByRef, selectSidebarThreadsForProjectRefs, - selectSidebarThreadsAcrossEnvironments, + selectThreadIdsByProjectRef, selectThreadByRef, useStore, } from "../store"; @@ -135,7 +138,15 @@ import { useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; -import { sortThreads } from "../lib/threadSort"; +import { + createSidebarProjectRenderStateSelector, + createSidebarProjectThreadStatusInputsSelector, + createSidebarThreadRowSnapshotSelectorByRef, + createSidebarThreadStatusInputSelectorByRef, + createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector, + type ProjectThreadStatusInput, + type SidebarThreadSortSnapshot, +} from "./sidebar/sidebarSelectors"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; @@ -147,7 +158,7 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { Project, SidebarThreadSummary } from "../types"; +import type { Project } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -163,23 +174,115 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); +const EMPTY_SIDEBAR_PROJECT_KEYS: string[] = []; +const EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY = new Map(); +const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); + +interface SidebarTransientState { + sortedProjectKeys: readonly string[]; + projectSnapshotByKey: ReadonlyMap; + physicalToLogicalKey: ReadonlyMap; + activeRouteThreadKey: string | null; + activeRouteProjectKey: string | null; + threadJumpLabelByKey: ReadonlyMap; + expandedThreadListsByProject: ReadonlySet; +} -function threadJumpLabelMapsEqual( - left: ReadonlyMap, - right: ReadonlyMap, -): boolean { - if (left === right) { - return true; - } - if (left.size !== right.size) { - return false; +const sidebarTransientStore = createStore(() => ({ + sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, + projectSnapshotByKey: EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY, + physicalToLogicalKey: new Map(), + activeRouteThreadKey: null, + activeRouteProjectKey: null, + threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, + expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, +})); + +function useSidebarProjectKeys(): readonly string[] { + return useZustandStore(sidebarTransientStore, (state) => state.sortedProjectKeys); +} + +function useSidebarPhysicalToLogicalKey(): ReadonlyMap { + return useZustandStore(sidebarTransientStore, (state) => state.physicalToLogicalKey); +} + +function useSidebarProjectSnapshot(projectKey: string): SidebarProjectSnapshot | null { + return useZustandStore( + sidebarTransientStore, + useCallback( + (state: SidebarTransientState) => state.projectSnapshotByKey.get(projectKey) ?? null, + [projectKey], + ), + ); +} + +function useSidebarProjectThreadListExpanded(projectKey: string): boolean { + return useZustandStore( + sidebarTransientStore, + useCallback( + (state: SidebarTransientState) => state.expandedThreadListsByProject.has(projectKey), + [projectKey], + ), + ); +} + +function expandSidebarProjectThreadList(projectKey: string): void { + const { expandedThreadListsByProject } = sidebarTransientStore.getState(); + if (expandedThreadListsByProject.has(projectKey)) { + return; } - for (const [key, value] of left) { - if (right.get(key) !== value) { - return false; - } + + sidebarTransientStore.setState({ + expandedThreadListsByProject: new Set([...expandedThreadListsByProject, projectKey]), + }); +} + +function collapseSidebarProjectThreadList(projectKey: string): void { + const { expandedThreadListsByProject } = sidebarTransientStore.getState(); + if (!expandedThreadListsByProject.has(projectKey)) { + return; } - return true; + + const nextExpandedThreadListsByProject = new Set(expandedThreadListsByProject); + nextExpandedThreadListsByProject.delete(projectKey); + sidebarTransientStore.setState({ + expandedThreadListsByProject: + nextExpandedThreadListsByProject.size === 0 + ? EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT + : nextExpandedThreadListsByProject, + }); +} + +function useSidebarIsActiveThread(threadKey: string): boolean { + return useZustandStore( + sidebarTransientStore, + useCallback( + (state: SidebarTransientState) => state.activeRouteThreadKey === threadKey, + [threadKey], + ), + ); +} + +function useSidebarThreadJumpLabel(threadKey: string): string | null { + return useZustandStore( + sidebarTransientStore, + useCallback( + (state: SidebarTransientState) => state.threadJumpLabelByKey.get(threadKey) ?? null, + [threadKey], + ), + ); +} + +function useSidebarProjectActiveRouteThreadKey(projectKey: string): string | null { + return useZustandStore( + sidebarTransientStore, + useCallback( + (state: SidebarTransientState) => { + return state.activeRouteProjectKey === projectKey ? state.activeRouteThreadKey : null; + }, + [projectKey], + ), + ); } function buildThreadJumpLabelMap(input: { @@ -221,6 +324,44 @@ type SidebarProjectSnapshot = Project & { /** Labels for remote environments this project lives in. */ remoteEnvironmentLabels: readonly string[]; }; + +function refsEqual(left: readonly ScopedProjectRef[], right: readonly ScopedProjectRef[]): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.projectId === right[index]?.projectId, + ) + ); +} + +function stringsEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function sidebarProjectSnapshotsEqual( + left: SidebarProjectSnapshot | undefined, + right: SidebarProjectSnapshot, +): boolean { + return ( + left !== undefined && + left.id === right.id && + left.environmentId === right.environmentId && + left.name === right.name && + left.cwd === right.cwd && + left.repositoryIdentity === right.repositoryIdentity && + left.defaultModelSelection === right.defaultModelSelection && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + left.scripts === right.scripts && + left.projectKey === right.projectKey && + left.environmentPresence === right.environmentPresence && + refsEqual(left.memberProjectRefs, right.memberProjectRefs) && + stringsEqual(left.remoteEnvironmentLabels, right.remoteEnvironmentLabels) + ); +} + interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -236,6 +377,19 @@ interface PrStatusIndicator { type ThreadPr = GitStatusResult["pr"]; +function useSidebarThreadStatusInput( + threadRef: ScopedThreadRef | null, +): ProjectThreadStatusInput | undefined { + return useStore( + useMemo(() => createSidebarThreadStatusInputSelectorByRef(threadRef), [threadRef]), + ); +} + +function useSidebarThreadRunningState(threadRef: ScopedThreadRef | null): boolean { + const statusInput = useSidebarThreadStatusInput(threadRef); + return statusInput?.session?.status === "running" && statusInput.session.activeTurnId != null; +} + function ThreadStatusLabel({ status, compact = false, @@ -328,77 +482,299 @@ function resolveThreadPr( return gitStatus.pr ?? null; } -interface SidebarThreadRowProps { - thread: SidebarThreadSummary; - projectCwd: string | null; - orderedProjectThreadKeys: readonly string[]; - isActive: boolean; - jumpLabel: string | null; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; -} +const SidebarThreadMetaInfo = memo(function SidebarThreadMetaInfo(props: { + threadKey: string; + threadRef: ScopedThreadRef; + isHighlighted: boolean; + isRemoteThread: boolean; + threadEnvironmentLabel: string | null; + isConfirmingArchive: boolean; +}) { + const { + threadKey, + threadRef, + isHighlighted, + isRemoteThread, + threadEnvironmentLabel, + isConfirmingArchive, + } = props; + const jumpLabel = useSidebarThreadJumpLabel(threadKey); + const isThreadRunning = useSidebarThreadRunningState(threadRef); + const hidden = isConfirmingArchive && !isThreadRunning; + const relativeTimestamp = useStore( + useMemo( + () => (state: import("../store").AppState) => { + const thread = selectSidebarThreadSummaryByRef(state, threadRef); + return thread ? formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt) : null; + }, + [threadRef], + ), + ); -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + return ( + + ); +}); + +const SidebarThreadStatusIndicator = memo(function SidebarThreadStatusIndicator(props: { + threadKey: string; + threadRef: ScopedThreadRef; +}) { + const { threadKey, threadRef } = props; + const statusInput = useSidebarThreadStatusInput(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const threadStatus = useMemo( + () => + statusInput + ? resolveThreadStatusPill({ + thread: { + ...statusInput, + lastVisitedAt, + }, + }) + : null, + [lastVisitedAt, statusInput], + ); + + return threadStatus ? : null; +}); + +const SidebarThreadTerminalStatusIndicator = memo( + function SidebarThreadTerminalStatusIndicator(props: { threadRef: ScopedThreadRef }) { + const { threadRef } = props; + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, + ); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + + return terminalStatus ? ( + + + + ) : null; + }, +); + +const SidebarThreadArchiveControl = memo(function SidebarThreadArchiveControl(props: { + appSettingsConfirmThreadArchive: boolean; + confirmArchiveButtonRef: React.RefObject; + handleArchiveImmediateClick: (event: React.MouseEvent) => void; + handleConfirmArchiveClick: (event: React.MouseEvent) => void; + handleStartArchiveConfirmation: (event: React.MouseEvent) => void; + isConfirmingArchive: boolean; + stopPropagationOnPointerDown: (event: React.PointerEvent) => void; + threadId: ThreadId; + threadRef: ScopedThreadRef; + threadTitle: string; +}) { const { - orderedProjectThreadKeys, - isActive, - jumpLabel, appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - thread, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchive, + stopPropagationOnPointerDown, + threadId, + threadRef, + threadTitle, } = props; - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const threadKey = scopedThreadKey(threadRef); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isThreadRunning = useSidebarThreadRunningState(threadRef); + const isConfirmingArchiveVisible = isConfirmingArchive && !isThreadRunning; + + if (isConfirmingArchiveVisible) { + return ( + + ); + } + + if (isThreadRunning) { + return null; + } + + if (appSettingsConfirmThreadArchive) { + return ( +
+ +
+ ); + } + + return ( + + + + + } + /> + Archive + + ); +}); + +interface SidebarThreadRowProps { + threadKey: string; + projectKey: string; +} + +const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const { threadKey, projectKey } = props; + const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); + const project = useSidebarProjectSnapshot(projectKey); + const threadSortOrder = useSettings( + (settings) => settings.sidebarThreadSortOrder, + ); + const appSettingsConfirmThreadDelete = useSettings( + (settings) => settings.confirmThreadDelete, + ); + const appSettingsConfirmThreadArchive = useSettings( + (settings) => settings.confirmThreadArchive, + ); + const router = useRouter(); + const { archiveThread, deleteThread } = useThreadActions(); + const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); + const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); + const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); + const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ + threadId: ThreadId; + }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: ctx.threadId, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ + path: string; + }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Path copied", + description: ctx.path, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); + const [isRenaming, setIsRenaming] = useState(false); + const [renamingTitle, setRenamingTitle] = useState(""); + const [isConfirmingArchive, setIsConfirmingArchive] = useState(false); + const renamingCommittedRef = useRef(false); + const renamingInputRef = useRef(null); + const confirmArchiveButtonRef = useRef(null); + if (!threadRef) { + return null; + } + const thread = useStore( + useMemo(() => createSidebarThreadRowSnapshotSelectorByRef(threadRef), [threadRef]), + ); + const isActive = useSidebarIsActiveThread(threadKey); + if (!thread) { + return null; + } const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); - const runningTerminalIds = useTerminalStateStore( - (state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, - ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; @@ -422,32 +798,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]); @@ -463,46 +824,306 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, [clearConfirmingArchive], ); - const handleRowClick = useCallback( - (event: React.MouseEvent) => { - handleThreadClick(event, threadRef, orderedProjectThreadKeys); - }, - [handleThreadClick, orderedProjectThreadKeys, threadRef], - ); - const handleRowKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(threadRef); - }, - [navigateToThread, threadRef], - ); - const handleRowContextMenu = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - if (hasSelection && isSelected) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - return; - } + const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); - if (hasSelection) { + const api = readLocalApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Link opening is unavailable.", + }); + return; + } + + void api.shell.openExternal(prUrl).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open PR link", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, []); + const navigateToThread = useCallback( + (targetThreadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { clearSelection(); } - void handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, + setSelectionAnchor(scopedThreadKey(targetThreadRef)); + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(targetThreadRef), }); }, - [ - clearSelection, - handleMultiSelectContextMenu, - handleThreadContextMenu, - hasSelection, - isSelected, - threadRef, + [clearSelection, router, setSelectionAnchor], + ); + const attemptArchiveThread = useCallback(async () => { + try { + await archiveThread(threadRef); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, [archiveThread, threadRef]); + const cancelRename = useCallback(() => { + setIsRenaming(false); + setRenamingTitle(""); + renamingCommittedRef.current = false; + renamingInputRef.current = null; + }, []); + const commitRename = useCallback(async () => { + const finishRename = () => { + setIsRenaming(false); + renamingCommittedRef.current = false; + renamingInputRef.current = null; + }; + + const trimmed = renamingTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Thread title cannot be empty", + }); + finishRename(); + return; + } + if (trimmed === thread.title) { + finishRename(); + return; + } + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + finishRename(); + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: threadRef.threadId, + title: trimmed, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + finishRename(); + }, [renamingTitle, thread.title, threadRef]); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + if (threadKeys.length === 0) return; + const count = threadKeys.length; + + const clicked = await api.contextMenu.show( + [ + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ); + + if (clicked === "mark-unread") { + const appState = useStore.getState(); + for (const selectedThreadKey of threadKeys) { + const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); + if (!selectedThreadRef) continue; + const selectedThread = selectSidebarThreadSummaryByRef(appState, selectedThreadRef); + markThreadUnread(selectedThreadKey, selectedThread?.latestTurn?.completedAt); + } + clearSelection(); + return; + } + + if (clicked !== "delete") return; + + if (appSettingsConfirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ); + if (!confirmed) return; + } + + const deletedThreadKeys = new Set(threadKeys); + for (const selectedThreadKey of threadKeys) { + const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); + if (!selectedThreadRef) continue; + await deleteThread(selectedThreadRef, { deletedThreadKeys }); + } + removeFromSelection(threadKeys); + }, + [ + appSettingsConfirmThreadDelete, + clearSelection, + deleteThread, + markThreadUnread, + removeFromSelection, + ], + ); + const handleThreadContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadWorkspacePath = thread.worktreePath ?? project?.cwd ?? null; + const currentThreadSummary = selectSidebarThreadSummaryByRef(useStore.getState(), threadRef); + const clicked = await api.contextMenu.show( + [ + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy Path" }, + { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + + if (clicked === "rename") { + setIsRenaming(true); + setRenamingTitle(thread.title); + renamingCommittedRef.current = false; + return; + } + + if (clicked === "mark-unread") { + markThreadUnread(threadKey, currentThreadSummary?.latestTurn?.completedAt); + return; + } + if (clicked === "copy-path") { + if (!threadWorkspacePath) { + toastManager.add({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }); + return; + } + copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + return; + } + if (clicked === "copy-thread-id") { + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + return; + } + if (clicked !== "delete") return; + if (appSettingsConfirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + } + await deleteThread(threadRef); + }, + [ + appSettingsConfirmThreadDelete, + copyPathToClipboard, + copyThreadIdToClipboard, + deleteThread, + markThreadUnread, + project?.cwd, + thread, + threadKey, + threadRef, + ], + ); + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const isShiftClick = event.shiftKey; + const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size; + + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + + if (isShiftClick) { + event.preventDefault(); + const projectSnapshot = sidebarTransientStore + .getState() + .projectSnapshotByKey.get(projectKey); + const orderedProjectThreadKeys = projectSnapshot + ? sortThreadsForSidebar( + selectSidebarThreadsForProjectRefs( + useStore.getState(), + projectSnapshot.memberProjectRefs, + ).filter((projectThread) => projectThread.archivedAt === null), + threadSortOrder, + ).map((projectThread) => + scopedThreadKey(scopeThreadRef(projectThread.environmentId, projectThread.id)), + ) + : [threadKey]; + rangeSelectTo(threadKey, orderedProjectThreadKeys); + return; + } + + if (currentSelectionCount > 0) { + clearSelection(); + } + navigateToThread(threadRef); + }, + [ + clearSelection, + navigateToThread, + projectKey, + rangeSelectTo, + threadKey, + threadRef, + threadSortOrder, + toggleThreadSelection, + ], + ); + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }, + [navigateToThread, threadRef], + ); + const handleRowContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (hasSelection && isSelected) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + return; + } + + if (hasSelection) { + clearSelection(); + } + void handleThreadContextMenu({ + x: event.clientX, + y: event.clientY, + }); + }, + [ + clearSelection, + handleMultiSelectContextMenu, + handleThreadContextMenu, + hasSelection, + isSelected, ], ); const handlePrClick = useCallback( @@ -534,33 +1155,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP if (event.key === "Enter") { event.preventDefault(); renamingCommittedRef.current = true; - void commitRename(threadRef, renamingTitle, thread.title); + void commitRename(); } else if (event.key === "Escape") { event.preventDefault(); renamingCommittedRef.current = true; cancelRename(); } }, - [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], + [cancelRename, commitRename, renamingCommittedRef], ); const handleRenameInputBlur = useCallback(() => { if (!renamingCommittedRef.current) { - void commitRename(threadRef, renamingTitle, thread.title); + void commitRename(); } - }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + }, [commitRename, renamingCommittedRef]); const handleRenameInputClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); }, []); - const handleConfirmArchiveRef = useCallback( - (element: HTMLButtonElement | null) => { - if (element) { - confirmArchiveButtonRefs.current.set(threadKey, element); - } else { - confirmArchiveButtonRefs.current.delete(threadKey); - } - }, - [confirmArchiveButtonRefs, threadKey], - ); const stopPropagationOnPointerDown = useCallback( (event: React.PointerEvent) => { event.stopPropagation(); @@ -572,28 +1183,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(() =>
, []); @@ -635,8 +1246,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)} - - )} - - + +
@@ -761,47 +1295,12 @@ interface SidebarProjectThreadListProps { projectKey: string; projectExpanded: boolean; hasOverflowingThreads: boolean; - hiddenThreadStatus: ThreadStatusPill | null; - orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; + hiddenThreadKeys: readonly string[]; + renderedThreadKeys: readonly string[]; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; - projectCwd: string; - activeRouteThreadKey: string | null; - threadJumpLabelByKey: ReadonlyMap; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; } const SidebarProjectThreadList = memo(function SidebarProjectThreadList( @@ -811,36 +1310,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( projectKey, projectExpanded, hasOverflowingThreads, - hiddenThreadStatus, - orderedProjectThreadKeys, - renderedThreads, + hiddenThreadKeys, + renderedThreadKeys, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, - projectCwd, - activeRouteThreadKey, - threadJumpLabelByKey, - appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, attachThreadListAutoAnimateRef, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - expandThreadListForProject, - collapseThreadListForProject, } = props; const showMoreButtonRender = useMemo(() => - + } - /> + > + + - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + Remote environment: {project.remoteEnvironmentLabels.join(", ")} - + )} + + + + + } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + + + ); +}); + +interface SidebarProjectItemProps { + projectKey: string; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + isManualProjectSorting: boolean; + dragHandleProps: SortableProjectHandleProps | null; +} + +const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { + const { + projectKey, + attachThreadListAutoAnimateRef, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + isManualProjectSorting, + dragHandleProps, + } = props; - + + + ); @@ -2036,19 +2201,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; - commandPaletteShortcutLabel: 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; @@ -2088,25 +2241,14 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleProjectDragStart, handleProjectDragEnd, handleProjectDragCancel, - handleNewThread, - archiveThread, - deleteThread, - sortedProjects, - expandedThreadListsByProject, - activeRouteProjectKey, - routeThreadKey, - newThreadShortcutLabel, - commandPaletteShortcutLabel, - threadJumpLabelByKey, attachThreadListAutoAnimateRef, - expandThreadListForProject, - collapseThreadListForProject, dragInProgressRef, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, attachProjectListAutoAnimateRef, projectsLength, } = props; + const sortedProjectKeys = useSidebarProjectKeys(); const handleProjectSortOrderChange = useCallback( (sortOrder: SidebarProjectSortOrder) => { @@ -2280,26 +2422,15 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( > project.projectKey)} + items={[...sortedProjectKeys]} strategy={verticalListSortingStrategy} > - {sortedProjects.map((project) => ( - + {sortedProjectKeys.map((projectKey) => ( + {(dragHandleProps) => ( ) : ( - {sortedProjects.map((project) => ( + {sortedProjectKeys.map((projectKey) => ( store.activeEnvironmentId); - const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const reorderProjects = useUiStateStore((store) => store.reorderProjects); - const navigate = useNavigate(); - const pathname = useLocation({ select: (loc) => loc.pathname }); - const isOnSettings = pathname.startsWith("/settings"); - const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); - const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); - const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); - const { updateSettings } = useUpdateSettings(); - const { handleNewThread } = useNewThreadHandler(); - const { archiveThread, deleteThread } = useThreadActions(); +function useSidebarKeyboardController(props: { + sortedProjectKeys: readonly string[]; + sidebarThreadSortSnapshots: readonly SidebarThreadSortSnapshot[]; + physicalToLogicalKey: ReadonlyMap; + expandedThreadListsByProject: ReadonlySet; + routeThreadRef: ScopedThreadRef | null; + routeThreadKey: string | null; + platform: string; + keybindings: ReturnType; + navigateToThread: (threadRef: ScopedThreadRef) => void; + sidebarThreadSortOrder: SidebarThreadSortOrder; +}) { + const { + sortedProjectKeys, + sidebarThreadSortSnapshots, + physicalToLogicalKey, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + sidebarThreadSortOrder, + } = props; + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const sidebarThreadSnapshotByKey = useMemo( + () => + new Map( + sidebarThreadSortSnapshots.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [sidebarThreadSortSnapshots], + ); + const threadsByProjectKey = useMemo(() => { + const next = new Map(); + for (const thread of sidebarThreadSortSnapshots) { + const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const existing = next.get(logicalKey); + if (existing) { + existing.push(thread); + } else { + next.set(logicalKey, [thread]); + } + } + return next; + }, [physicalToLogicalKey, sidebarThreadSortSnapshots]); + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjectKeys.flatMap((projectKey) => { + const projectThreads = sortThreadsForSidebar( + (threadsByProjectKey.get(projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + ); + const projectExpanded = projectExpandedById[projectKey] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeThreadKey, + ) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); + const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const previewThreads = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreads + : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + }), + [ + expandedThreadListsByProject, + projectExpandedById, + routeThreadKey, + sidebarThreadSortOrder, + sortedProjectKeys, + threadsByProjectKey, + ], + ); + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(threadKey, jumpCommand); + } + + return mapping; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }), + [routeThreadRef], + ); + const threadJumpLabelByKey = useMemo( + () => + showThreadJumpHints + ? buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: getCurrentSidebarShortcutContext().terminalOpen, + threadJumpCommandByKey, + }) + : EMPTY_THREAD_JUMP_LABELS, + [ + getCurrentSidebarShortcutContext, + keybindings, + platform, + showThreadJumpHints, + threadJumpCommandByKey, + ], + ); + const threadJumpLabelsRef = useRef>(threadJumpLabelByKey); + threadJumpLabelsRef.current = threadJumpLabelByKey; + const showThreadJumpHintsRef = useRef(showThreadJumpHints); + showThreadJumpHintsRef.current = showThreadJumpHints; + + useEffect(() => { + const clearThreadJumpHints = () => { + updateThreadJumpHintsVisibility(false); + }; + const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + event.key !== "Meta" && + event.key !== "Control" && + event.key !== "Alt" && + event.key !== "Shift" && + !showThreadJumpHintsRef.current && + threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + if ( + showThreadJumpHintsRef.current || + threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS + ) { + clearThreadJumpHints(); + } + } else { + updateThreadJumpHintsVisibility(true); + } + + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: shortcutContext, + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: visibleSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }); + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + }; + + const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + clearThreadJumpHints(); + return; + } + updateThreadJumpHintsVisibility(true); + }; + + const onWindowBlur = () => { + clearThreadJumpHints(); + }; + + window.addEventListener("keydown", onWindowKeyDown); + window.addEventListener("keyup", onWindowKeyUp); + window.addEventListener("blur", onWindowBlur); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + window.removeEventListener("keyup", onWindowKeyUp); + window.removeEventListener("blur", onWindowBlur); + }; + }, [ + getCurrentSidebarShortcutContext, + keybindings, + navigateToThread, + platform, + routeThreadKey, + sidebarThreadSnapshotByKey, + threadJumpCommandByKey, + threadJumpThreadKeys, + updateThreadJumpHintsVisibility, + visibleSidebarThreadKeys, + ]); + + return threadJumpLabelByKey; +} + +const SidebarProjectOrderingController = memo(function SidebarProjectOrderingController(props: { + sidebarProjects: readonly SidebarProjectSnapshot[]; + physicalToLogicalKey: ReadonlyMap; + sidebarProjectSortOrder: SidebarProjectSortOrder; +}) { + const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; + const sidebarThreadSortSnapshots = useStore( + useMemo(() => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(), []), + ); + const sortedProjectKeys = useMemo(() => { + if (sidebarProjectSortOrder === "manual") { + return sidebarProjects.map((project) => project.projectKey); + } + + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = sidebarThreadSortSnapshots + .filter((thread) => thread.archivedAt === null) + .map((thread) => { + const physicalKey = scopedProjectKey( + scopeProjectRef(thread.environmentId, thread.projectId), + ); + return { + id: thread.id, + environmentId: thread.environmentId, + projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + latestUserMessageAt: thread.latestUserMessageAt, + }; + }); + return sortProjectsForSidebar(sortableProjects, sortableThreads, sidebarProjectSortOrder).map( + (project) => project.id, + ); + }, [sidebarProjectSortOrder, sidebarProjects, sidebarThreadSortSnapshots, physicalToLogicalKey]); + + useEffect(() => { + const currentState = sidebarTransientStore.getState(); + if (stringsEqual(currentState.sortedProjectKeys, sortedProjectKeys)) { + return; + } + + sidebarTransientStore.setState({ + sortedProjectKeys, + }); + }, [sortedProjectKeys]); + + return null; +}); + +const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { + navigateToThread: (threadRef: ScopedThreadRef) => void; + sidebarThreadSortOrder: SidebarThreadSortOrder; +}) { + const { navigateToThread, sidebarThreadSortOrder } = props; + const sortedProjectKeys = useSidebarProjectKeys(); + const physicalToLogicalKey = useSidebarPhysicalToLogicalKey(); + const expandedThreadListsByProject = useZustandStore( + sidebarTransientStore, + (state) => state.expandedThreadListsByProject, + ); + const sidebarThreadSortSnapshots = useStore( + useMemo(() => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(), []), + ); const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const activeRouteProjectKey = useMemo(() => { + if (!routeThreadRef) { + return null; + } + + const activeThread = sidebarThreadSortSnapshots.find( + (thread) => + thread.environmentId === routeThreadRef.environmentId && + thread.id === routeThreadRef.threadId, + ); + if (!activeThread) { + return null; + } + + const physicalKey = scopedProjectKey( + scopeProjectRef(activeThread.environmentId, activeThread.projectId), + ); + return physicalToLogicalKey.get(physicalKey) ?? physicalKey; + }, [physicalToLogicalKey, routeThreadRef, sidebarThreadSortSnapshots]); const keybindings = useServerKeybindings(); + const platform = navigator.platform; + const threadJumpLabelByKey = useSidebarKeyboardController({ + sortedProjectKeys, + sidebarThreadSortSnapshots, + physicalToLogicalKey, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + sidebarThreadSortOrder, + }); + + useEffect(() => { + const currentState = sidebarTransientStore.getState(); + if ( + currentState.activeRouteThreadKey === routeThreadKey && + currentState.activeRouteProjectKey === activeRouteProjectKey && + currentState.threadJumpLabelByKey === threadJumpLabelByKey + ) { + return; + } + + sidebarTransientStore.setState({ + activeRouteThreadKey: routeThreadKey, + activeRouteProjectKey, + threadJumpLabelByKey, + }); + }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); + + return null; +}); + +export default function Sidebar() { + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const navigate = useNavigate(); + const isOnSettings = useLocation({ select: (loc) => loc.pathname.startsWith("/settings") }); + const settingsPathname = useLocation({ + select: (loc) => (loc.pathname.startsWith("/settings") ? loc.pathname : "/settings"), + }); + const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); + const { updateSettings } = useUpdateSettings(); + const { handleNewThread } = useNewThreadHandler(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); - const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< - ReadonlySet - >(() => new Set()); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); @@ -2392,7 +2902,6 @@ export default function Sidebar() { const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); - const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -2418,6 +2927,9 @@ export default function Sidebar() { return mapping; }, [orderedProjects]); + const previousSidebarProjectSnapshotByKeyRef = useRef< + ReadonlyMap + >(EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY); const sidebarProjects = useMemo(() => { // Group projects by logical key while preserving insertion order from // orderedProjects. @@ -2432,6 +2944,8 @@ export default function Sidebar() { } } + const previousSidebarProjectSnapshotByKey = previousSidebarProjectSnapshotByKeyRef.current; + const nextSidebarProjectSnapshotByKey = new Map(); const result: SidebarProjectSnapshot[] = []; const seen = new Set(); for (const project of orderedProjects) { @@ -2462,109 +2976,60 @@ export default function Sidebar() { const saved = savedEnvironmentRegistry[p.environmentId]; return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; }); - const snapshot: SidebarProjectSnapshot = { + const nextSnapshot: SidebarProjectSnapshot = { id: representative.id, environmentId: representative.environmentId, name: representative.name, cwd: representative.cwd, repositoryIdentity: representative.repositoryIdentity ?? null, - defaultModelSelection: representative.defaultModelSelection, - createdAt: representative.createdAt, - updatedAt: representative.updatedAt, - scripts: representative.scripts, - projectKey: logicalKey, - environmentPresence: - hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", - memberProjectRefs: refs, - remoteEnvironmentLabels: remoteLabels, - }; - result.push(snapshot); - } - return result; - }, [ - orderedProjects, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); - - const sidebarProjectByKey = useMemo( - () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), - [sidebarProjects], - ); - const sidebarThreadByKey = useMemo( - () => - new Map( - sidebarThreads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), - [sidebarThreads], - ); - // Resolve the active route's project key to a logical key so it matches the - // sidebar's grouped project entries. - const activeRouteProjectKey = useMemo(() => { - if (!routeThreadKey) { - return null; - } - const activeThread = sidebarThreadByKey.get(routeThreadKey); - if (!activeThread) return null; - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); - return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); - - // Group threads by logical project key so all threads from grouped projects - // are displayed together. - const threadsByProjectKey = useMemo(() => { - const next = new Map(); - for (const thread of sidebarThreads) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const existing = next.get(logicalKey); - if (existing) { - existing.push(thread); - } else { - next.set(logicalKey, [thread]); - } + defaultModelSelection: representative.defaultModelSelection, + createdAt: representative.createdAt, + updatedAt: representative.updatedAt, + scripts: representative.scripts, + projectKey: logicalKey, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjectRefs: refs, + remoteEnvironmentLabels: remoteLabels, + }; + const snapshot = sidebarProjectSnapshotsEqual( + previousSidebarProjectSnapshotByKey.get(logicalKey), + nextSnapshot, + ) + ? (previousSidebarProjectSnapshotByKey.get(logicalKey) as SidebarProjectSnapshot) + : nextSnapshot; + nextSidebarProjectSnapshotByKey.set(logicalKey, snapshot); + result.push(snapshot); } - return next; - }, [sidebarThreads, physicalToLogicalKey]); - const getCurrentSidebarShortcutContext = useCallback( - () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - }), - [routeThreadRef], - ); - const newThreadShortcutLabelOptions = useMemo( - () => ({ - platform, - context: { - terminalFocus: false, - terminalOpen: false, - }, - }), - [platform], + previousSidebarProjectSnapshotByKeyRef.current = + nextSidebarProjectSnapshotByKey.size === 0 + ? EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY + : nextSidebarProjectSnapshotByKey; + return result; + }, [ + orderedProjects, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + + const sidebarProjectByKey = useMemo( + () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + [sidebarProjects], ); - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); const focusMostRecentThreadForProject = useCallback( (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { const physicalKey = scopedProjectKey( scopeProjectRef(projectRef.environmentId, projectRef.projectId), ); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const latestThread = sortThreads( - (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), + const memberProjectRefs = sidebarProjectByKey.get(logicalKey)?.memberProjectRefs ?? [ + projectRef, + ]; + const latestThread = sortThreadsForSidebar( + selectSidebarThreadsForProjectRefs(useStore.getState(), memberProjectRefs).filter( + (thread) => thread.archivedAt === null, + ), sidebarThreadSortOrder, )[0]; if (!latestThread) return; @@ -2574,7 +3039,7 @@ export default function Sidebar() { params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), }); }, - [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], + [navigate, physicalToLogicalKey, sidebarProjectByKey, sidebarThreadSortOrder], ); const addProjectFromInput = useCallback( @@ -2763,254 +3228,22 @@ export default function Sidebar() { animatedThreadListsRef.current.add(node); }, []); - const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], - ); - const sortedProjects = useMemo(() => { - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - const sortableThreads = visibleThreads.map((thread) => { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - return { - ...thread, - projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, - }; - }); - return sortProjectsForSidebar( - sortableProjects, - sortableThreads, - sidebarProjectSortOrder, - ).flatMap((project) => { - const resolvedProject = sidebarProjectByKey.get(project.id); - return resolvedProject ? [resolvedProject] : []; - }); - }, [ - sidebarProjectSortOrder, - physicalToLogicalKey, - sidebarProjectByKey, - sidebarProjects, - visibleThreads, - ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - const visibleSidebarThreadKeys = useMemo( - () => - sortedProjects.flatMap((project) => { - const projectThreads = sortThreads( - (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, - ), - sidebarThreadSortOrder, - ); - const projectExpanded = projectExpandedById[project.projectKey] ?? true; - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - }), - [ - sidebarThreadSortOrder, - expandedThreadListsByProject, - projectExpandedById, - routeThreadKey, - sortedProjects, - threadsByProjectKey, - ], - ); - const threadJumpCommandByKey = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - return mapping; - } - mapping.set(threadKey, jumpCommand); - } - - return mapping; - }, [visibleSidebarThreadKeys]); - const threadJumpThreadKeys = useMemo( - () => [...threadJumpCommandByKey.keys()], - [threadJumpCommandByKey], - ); - const [threadJumpLabelByKey, setThreadJumpLabelByKey] = - useState>(EMPTY_THREAD_JUMP_LABELS); - const threadJumpLabelsRef = useRef>(EMPTY_THREAD_JUMP_LABELS); - threadJumpLabelsRef.current = threadJumpLabelByKey; - const showThreadJumpHintsRef = useRef(showThreadJumpHints); - showThreadJumpHintsRef.current = showThreadJumpHints; - const visibleThreadJumpLabelByKey = showThreadJumpHints - ? threadJumpLabelByKey - : EMPTY_THREAD_JUMP_LABELS; - const orderedSidebarThreadKeys = visibleSidebarThreadKeys; useEffect(() => { - const clearThreadJumpHints = () => { - setThreadJumpLabelByKey((current) => - current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, - ); - updateThreadJumpHintsVisibility(false); - }; - const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - event.key !== "Meta" && - event.key !== "Control" && - event.key !== "Alt" && - event.key !== "Shift" && - !showThreadJumpHintsRef.current && - threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; - - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - if ( - showThreadJumpHintsRef.current || - threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS - ) { - clearThreadJumpHints(); - } - } else { - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - } - - if (event.defaultPrevented || event.repeat) { - return; - } - - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, - }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return; - } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - }; - - const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - clearThreadJumpHints(); - return; - } - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - }; - - const onWindowBlur = () => { - clearThreadJumpHints(); - }; - - window.addEventListener("keydown", onWindowKeyDown); - window.addEventListener("keyup", onWindowKeyUp); - window.addEventListener("blur", onWindowBlur); + const currentState = sidebarTransientStore.getState(); + if ( + currentState.projectSnapshotByKey === sidebarProjectByKey && + currentState.physicalToLogicalKey === physicalToLogicalKey + ) { + return; + } - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); - }; - }, [ - getCurrentSidebarShortcutContext, - keybindings, - navigateToThread, - orderedSidebarThreadKeys, - platform, - routeThreadKey, - sidebarThreadByKey, - threadJumpCommandByKey, - threadJumpThreadKeys, - updateThreadJumpHintsVisibility, - ]); + sidebarTransientStore.setState({ + projectSnapshotByKey: sidebarProjectByKey, + physicalToLogicalKey, + }); + }, [physicalToLogicalKey, sidebarProjectByKey]); useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { @@ -3136,30 +3369,21 @@ export default function Sidebar() { } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - const expandThreadListForProject = useCallback((projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (current.has(projectKey)) return current; - const next = new Set(current); - next.add(projectKey); - return next; - }); - }, []); - - const collapseThreadListForProject = useCallback((projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (!current.has(projectKey)) return current; - const next = new Set(current); - next.delete(projectKey); - return next; - }); - }, []); - return ( <> + + {isOnSettings ? ( - + ) : ( <> value === right[index]); +} + +export interface ProjectThreadStatusInput { + threadKey: string; + hasActionableProposedPlan: boolean; + hasPendingApprovals: boolean; + hasPendingUserInput: boolean; + interactionMode: SidebarThreadSummary["interactionMode"]; + latestTurn: SidebarThreadSummary["latestTurn"]; + session: SidebarThreadSummary["session"]; +} + +export interface SidebarThreadRowSnapshot { + id: ThreadId; + environmentId: EnvironmentId; + projectId: ProjectId; + title: string; + branch: string | null; + worktreePath: string | null; +} + +interface ProjectThreadRenderEntry { + threadKey: string; + id: ThreadId; + environmentId: EnvironmentId; + projectId: ProjectId; + createdAt: string; + archivedAt: string | null; + updatedAt?: string | undefined; + latestUserMessageAt: string | null; +} + +export interface SidebarProjectRenderStateSnapshot { + hasOverflowingThreads: boolean; + hiddenThreadKeys: readonly string[]; + renderedThreadKeys: readonly string[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; +} + +const EMPTY_PROJECT_RENDER_STATE: SidebarProjectRenderStateSnapshot = { + hasOverflowingThreads: false, + hiddenThreadKeys: EMPTY_PROJECT_THREAD_KEYS, + renderedThreadKeys: EMPTY_PROJECT_THREAD_KEYS, + showEmptyThreadState: false, + shouldShowThreadPanel: false, +}; + +function collectProjectThreadEntries( + state: AppState, + memberProjectRefs: readonly ScopedProjectRef[], +): ProjectThreadRenderEntry[] { + if (memberProjectRefs.length === 0) { + return []; + } + + const entries: ProjectThreadRenderEntry[] = []; + for (const ref of memberProjectRefs) { + const environmentState = state.environmentStateById[ref.environmentId]; + if (!environmentState) { + continue; + } + const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? []; + for (const threadId of threadIds) { + const summary = environmentState.sidebarThreadSummaryById[threadId]; + if (!summary) { + continue; + } + entries.push({ + threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), + id: summary.id, + environmentId: summary.environmentId, + projectId: summary.projectId, + createdAt: summary.createdAt, + archivedAt: summary.archivedAt, + updatedAt: summary.updatedAt, + latestUserMessageAt: summary.latestUserMessageAt, + }); + } + } + return entries; +} + +function collectProjectThreadStatusInputs( + state: AppState, + memberProjectRefs: readonly ScopedProjectRef[], +): ProjectThreadStatusInput[] { + if (memberProjectRefs.length === 0) { + return []; + } + + const inputs: ProjectThreadStatusInput[] = []; + for (const ref of memberProjectRefs) { + const environmentState = state.environmentStateById[ref.environmentId]; + if (!environmentState) { + continue; + } + const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? []; + for (const threadId of threadIds) { + const summary = environmentState.sidebarThreadSummaryById[threadId]; + if (!summary) { + continue; + } + inputs.push({ + threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), + hasActionableProposedPlan: summary.hasActionableProposedPlan, + hasPendingApprovals: summary.hasPendingApprovals, + hasPendingUserInput: summary.hasPendingUserInput, + interactionMode: summary.interactionMode, + latestTurn: summary.latestTurn, + session: summary.session, + }); + } + } + return inputs; +} + +function projectThreadStatusInputsEqual( + left: ProjectThreadStatusInput | undefined, + right: ProjectThreadStatusInput | undefined, +): boolean { + return ( + left !== undefined && + right !== undefined && + left.threadKey === right.threadKey && + left.hasActionableProposedPlan === right.hasActionableProposedPlan && + left.hasPendingApprovals === right.hasPendingApprovals && + left.hasPendingUserInput === right.hasPendingUserInput && + left.interactionMode === right.interactionMode && + left.latestTurn?.startedAt === right.latestTurn?.startedAt && + left.latestTurn?.completedAt === right.latestTurn?.completedAt && + left.session?.orchestrationStatus === right.session?.orchestrationStatus && + left.session?.activeTurnId === right.session?.activeTurnId && + left.session?.status === right.session?.status + ); +} + +export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(): ( + state: AppState, +) => SidebarThreadSortSnapshot[] { + let previousResult = EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS; + let previousEntries = new Map(); + + return (state) => { + const nextEntries = new Map(); + const nextResult: SidebarThreadSortSnapshot[] = []; + let changed = false; + + for (const [environmentId, environmentState] of Object.entries( + state.environmentStateById, + ) as Array<[EnvironmentId, EnvironmentState]>) { + for (const threadId of environmentState.threadIds) { + const summary = environmentState.sidebarThreadSummaryById[threadId]; + if (!summary || summary.environmentId !== environmentId) { + continue; + } + + const entryKey = `${environmentId}:${threadId}`; + const previousEntry = previousEntries.get(entryKey); + if ( + previousEntry && + previousEntry.id === summary.id && + previousEntry.environmentId === summary.environmentId && + previousEntry.projectId === summary.projectId && + previousEntry.createdAt === summary.createdAt && + previousEntry.archivedAt === summary.archivedAt && + previousEntry.updatedAt === summary.updatedAt && + previousEntry.latestUserMessageAt === summary.latestUserMessageAt + ) { + nextEntries.set(entryKey, previousEntry); + nextResult.push(previousEntry); + if (previousResult[nextResult.length - 1] !== previousEntry) { + changed = true; + } + continue; + } + + const snapshot: SidebarThreadSortSnapshot = { + id: summary.id, + environmentId: summary.environmentId, + projectId: summary.projectId, + createdAt: summary.createdAt, + archivedAt: summary.archivedAt, + updatedAt: summary.updatedAt, + latestUserMessageAt: summary.latestUserMessageAt, + }; + nextEntries.set(entryKey, snapshot); + nextResult.push(snapshot); + changed = true; + } + } + + if (previousResult.length !== nextResult.length) { + changed = true; + } + + if (!changed) { + previousEntries = nextEntries; + return previousResult; + } + + previousEntries = nextEntries; + previousResult = nextResult.length === 0 ? EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS : nextResult; + return previousResult; + }; +} + +export function createSidebarThreadRowSnapshotSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => SidebarThreadRowSnapshot | undefined { + let previousResult: SidebarThreadRowSnapshot | undefined; + + return (state) => { + if (!ref) { + return undefined; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + return undefined; + } + + const nextResult: SidebarThreadRowSnapshot = { + id: summary.id, + environmentId: summary.environmentId, + projectId: summary.projectId, + title: summary.title, + branch: summary.branch, + worktreePath: summary.worktreePath ?? null, + }; + + if ( + previousResult && + previousResult.id === nextResult.id && + previousResult.environmentId === nextResult.environmentId && + previousResult.projectId === nextResult.projectId && + previousResult.title === nextResult.title && + previousResult.branch === nextResult.branch && + previousResult.worktreePath === nextResult.worktreePath + ) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarThreadStatusInputSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => ProjectThreadStatusInput | undefined { + let previousResult: ProjectThreadStatusInput | undefined; + + return (state) => { + if (!ref) { + return undefined; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + return undefined; + } + + const nextResult: ProjectThreadStatusInput = { + threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), + hasActionableProposedPlan: summary.hasActionableProposedPlan, + hasPendingApprovals: summary.hasPendingApprovals, + hasPendingUserInput: summary.hasPendingUserInput, + interactionMode: summary.interactionMode, + latestTurn: summary.latestTurn, + session: summary.session, + }; + + if (projectThreadStatusInputsEqual(previousResult, nextResult)) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarProjectRenderStateSelector(input: { + activeRouteThreadKey: string | null; + isThreadListExpanded: boolean; + memberProjectRefs: readonly ScopedProjectRef[]; + projectExpanded: boolean; + previewLimit: number; + threadSortOrder: SidebarThreadSortOrder; +}): (state: AppState) => SidebarProjectRenderStateSnapshot { + let previousResult = EMPTY_PROJECT_RENDER_STATE; + + return (state) => { + const visibleProjectThreads = sortThreadsForSidebar( + collectProjectThreadEntries(state, input.memberProjectRefs).filter( + (thread) => thread.archivedAt === null, + ), + input.threadSortOrder, + ); + const pinnedCollapsedThread = + !input.projectExpanded && input.activeRouteThreadKey + ? (visibleProjectThreads.find( + (thread) => thread.threadKey === input.activeRouteThreadKey, + ) ?? null) + : null; + const shouldShowThreadPanel = input.projectExpanded || pinnedCollapsedThread !== null; + const hasOverflowingThreads = visibleProjectThreads.length > input.previewLimit; + const previewThreads = + input.isThreadListExpanded || !hasOverflowingThreads + ? visibleProjectThreads + : visibleProjectThreads.slice(0, input.previewLimit); + const renderedThreadKeys = pinnedCollapsedThread + ? [pinnedCollapsedThread.threadKey] + : previewThreads.map((thread) => thread.threadKey); + const renderedThreadKeySet = new Set(renderedThreadKeys); + const hiddenThreadKeys = visibleProjectThreads + .filter((thread) => !renderedThreadKeySet.has(thread.threadKey)) + .map((thread) => thread.threadKey); + const nextResult: SidebarProjectRenderStateSnapshot = { + hasOverflowingThreads, + hiddenThreadKeys: + hiddenThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : hiddenThreadKeys, + renderedThreadKeys: + renderedThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : renderedThreadKeys, + showEmptyThreadState: input.projectExpanded && visibleProjectThreads.length === 0, + shouldShowThreadPanel, + }; + + if ( + previousResult.hasOverflowingThreads === nextResult.hasOverflowingThreads && + previousResult.showEmptyThreadState === nextResult.showEmptyThreadState && + previousResult.shouldShowThreadPanel === nextResult.shouldShowThreadPanel && + stringArraysEqual(previousResult.renderedThreadKeys, nextResult.renderedThreadKeys) && + stringArraysEqual(previousResult.hiddenThreadKeys, nextResult.hiddenThreadKeys) + ) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarProjectThreadStatusInputsSelector( + memberProjectRefs: readonly ScopedProjectRef[], +): (state: AppState) => readonly ProjectThreadStatusInput[] { + let previousResult: readonly ProjectThreadStatusInput[] = EMPTY_PROJECT_THREAD_STATUS_INPUTS; + + return (state) => { + const nextInputs = collectProjectThreadStatusInputs(state, memberProjectRefs); + if ( + previousResult.length === nextInputs.length && + previousResult.every((previousInput, index) => { + const nextInput = nextInputs[index]; + return nextInput !== undefined && projectThreadStatusInputsEqual(previousInput, nextInput); + }) + ) { + return previousResult; + } + + previousResult = nextInputs.length === 0 ? EMPTY_PROJECT_THREAD_STATUS_INPUTS : nextInputs; + return previousResult; + }; +} From 36f7df130adea678502d1885ee75da93c2dc88de Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:51:19 +0100 Subject: [PATCH 02/21] Polish sidebar atomic selectors and controllers --- apps/web/src/components/Sidebar.logic.ts | 32 +- apps/web/src/components/Sidebar.tsx | 658 +----------------- .../components/sidebar/sidebarConstants.ts | 1 + .../components/sidebar/sidebarControllers.tsx | 471 +++++++++++++ .../components/sidebar/sidebarSelectors.ts | 78 ++- .../components/sidebar/sidebarViewStore.ts | 222 ++++++ apps/web/src/logicalProject.ts | 6 +- 7 files changed, 812 insertions(+), 656 deletions(-) create mode 100644 apps/web/src/components/sidebar/sidebarConstants.ts create mode 100644 apps/web/src/components/sidebar/sidebarControllers.tsx create mode 100644 apps/web/src/components/sidebar/sidebarViewStore.ts diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e0fbb6439f..30bd2d3f6e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -20,6 +20,12 @@ type SidebarProject = { updatedAt?: string | undefined; }; +type SidebarThreadSortInput = Pick & { + projectId: string; + latestUserMessageAt?: string | null; + messages?: Pick[]; +}; + export type ThreadTraversalDirection = "previous" | "next"; export interface ThreadStatusPill { @@ -46,16 +52,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; @@ -489,7 +507,7 @@ export function getProjectSortTimestamp( export function sortProjectsForSidebar< TProject extends SidebarProject, - TThread extends Pick & ThreadSortInput, + 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 1ee381a1d7..565b143a0a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -15,8 +15,6 @@ import { import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; -import { useStore as useZustandStore } from "zustand"; -import { createStore } from "zustand/vanilla"; import { useShallow } from "zustand/react/shallow"; import { DndContext, @@ -38,7 +36,6 @@ import { type DesktopUpdateState, type EnvironmentId, ProjectId, - type ScopedProjectRef, type ScopedThreadRef, type ThreadEnvMode, ThreadId, @@ -51,7 +48,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; -import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, @@ -59,7 +56,6 @@ import { import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; -import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { selectProjectByRef, @@ -72,25 +68,14 @@ import { } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; -import { - resolveShortcutCommand, - shortcutLabelForCommand, - shouldShowThreadJumpHints, - threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, -} from "../keybindings"; +import { shortcutLabelForCommand } from "../keybindings"; import { useGitStatus } from "../lib/gitStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; -import { - buildThreadRouteParams, - resolveThreadRouteRef, - resolveThreadRouteTarget, -} from "../threadRoutes"; +import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -125,7 +110,6 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { - resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, @@ -134,8 +118,7 @@ import { resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, - sortProjectsForSidebar, - useThreadJumpHintVisibility, + sortThreadsForSidebar, ThreadStatusPill, } from "./Sidebar.logic"; import { @@ -143,10 +126,26 @@ import { createSidebarProjectThreadStatusInputsSelector, createSidebarThreadRowSnapshotSelectorByRef, createSidebarThreadStatusInputSelectorByRef, - createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector, type ProjectThreadStatusInput, - type SidebarThreadSortSnapshot, } from "./sidebar/sidebarSelectors"; +import { THREAD_PREVIEW_LIMIT } from "./sidebar/sidebarConstants"; +import { + SidebarKeyboardController, + SidebarProjectOrderingController, +} from "./sidebar/sidebarControllers"; +import { + collapseSidebarProjectThreadList, + expandSidebarProjectThreadList, + sidebarProjectSnapshotsEqual, + syncSidebarProjectMappings, + useSidebarIsActiveThread, + useSidebarProjectActiveRouteThreadKey, + useSidebarProjectKeys, + useSidebarProjectSnapshot, + useSidebarProjectThreadListExpanded, + useSidebarThreadJumpLabel, + type SidebarProjectSnapshot, +} from "./sidebar/sidebarViewStore"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; @@ -159,7 +158,6 @@ import { useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import type { Project } from "../types"; -const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -173,194 +171,6 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; -const EMPTY_THREAD_JUMP_LABELS = new Map(); -const EMPTY_SIDEBAR_PROJECT_KEYS: string[] = []; -const EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY = new Map(); -const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); - -interface SidebarTransientState { - sortedProjectKeys: readonly string[]; - projectSnapshotByKey: ReadonlyMap; - physicalToLogicalKey: ReadonlyMap; - activeRouteThreadKey: string | null; - activeRouteProjectKey: string | null; - threadJumpLabelByKey: ReadonlyMap; - expandedThreadListsByProject: ReadonlySet; -} - -const sidebarTransientStore = createStore(() => ({ - sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, - projectSnapshotByKey: EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY, - physicalToLogicalKey: new Map(), - activeRouteThreadKey: null, - activeRouteProjectKey: null, - threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, - expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, -})); - -function useSidebarProjectKeys(): readonly string[] { - return useZustandStore(sidebarTransientStore, (state) => state.sortedProjectKeys); -} - -function useSidebarPhysicalToLogicalKey(): ReadonlyMap { - return useZustandStore(sidebarTransientStore, (state) => state.physicalToLogicalKey); -} - -function useSidebarProjectSnapshot(projectKey: string): SidebarProjectSnapshot | null { - return useZustandStore( - sidebarTransientStore, - useCallback( - (state: SidebarTransientState) => state.projectSnapshotByKey.get(projectKey) ?? null, - [projectKey], - ), - ); -} - -function useSidebarProjectThreadListExpanded(projectKey: string): boolean { - return useZustandStore( - sidebarTransientStore, - useCallback( - (state: SidebarTransientState) => state.expandedThreadListsByProject.has(projectKey), - [projectKey], - ), - ); -} - -function expandSidebarProjectThreadList(projectKey: string): void { - const { expandedThreadListsByProject } = sidebarTransientStore.getState(); - if (expandedThreadListsByProject.has(projectKey)) { - return; - } - - sidebarTransientStore.setState({ - expandedThreadListsByProject: new Set([...expandedThreadListsByProject, projectKey]), - }); -} - -function collapseSidebarProjectThreadList(projectKey: string): void { - const { expandedThreadListsByProject } = sidebarTransientStore.getState(); - if (!expandedThreadListsByProject.has(projectKey)) { - return; - } - - const nextExpandedThreadListsByProject = new Set(expandedThreadListsByProject); - nextExpandedThreadListsByProject.delete(projectKey); - sidebarTransientStore.setState({ - expandedThreadListsByProject: - nextExpandedThreadListsByProject.size === 0 - ? EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT - : nextExpandedThreadListsByProject, - }); -} - -function useSidebarIsActiveThread(threadKey: string): boolean { - return useZustandStore( - sidebarTransientStore, - useCallback( - (state: SidebarTransientState) => state.activeRouteThreadKey === threadKey, - [threadKey], - ), - ); -} - -function useSidebarThreadJumpLabel(threadKey: string): string | null { - return useZustandStore( - sidebarTransientStore, - useCallback( - (state: SidebarTransientState) => state.threadJumpLabelByKey.get(threadKey) ?? null, - [threadKey], - ), - ); -} - -function useSidebarProjectActiveRouteThreadKey(projectKey: string): string | null { - return useZustandStore( - sidebarTransientStore, - useCallback( - (state: SidebarTransientState) => { - return state.activeRouteProjectKey === projectKey ? state.activeRouteThreadKey : null; - }, - [projectKey], - ), - ); -} - -function buildThreadJumpLabelMap(input: { - keybindings: ReturnType; - platform: string; - terminalOpen: boolean; - threadJumpCommandByKey: ReadonlyMap< - string, - NonNullable> - >; -}): ReadonlyMap { - if (input.threadJumpCommandByKey.size === 0) { - return EMPTY_THREAD_JUMP_LABELS; - } - - const shortcutLabelOptions = { - platform: input.platform, - context: { - terminalFocus: false, - terminalOpen: input.terminalOpen, - }, - } as const; - const mapping = new Map(); - for (const [threadKey, command] of input.threadJumpCommandByKey) { - const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); - if (label) { - mapping.set(threadKey, label); - } - } - return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; -} - -type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; - -type SidebarProjectSnapshot = Project & { - projectKey: string; - environmentPresence: EnvironmentPresence; - memberProjectRefs: readonly ScopedProjectRef[]; - /** Labels for remote environments this project lives in. */ - remoteEnvironmentLabels: readonly string[]; -}; - -function refsEqual(left: readonly ScopedProjectRef[], right: readonly ScopedProjectRef[]): boolean { - return ( - left.length === right.length && - left.every( - (ref, index) => - ref.environmentId === right[index]?.environmentId && - ref.projectId === right[index]?.projectId, - ) - ); -} - -function stringsEqual(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - -function sidebarProjectSnapshotsEqual( - left: SidebarProjectSnapshot | undefined, - right: SidebarProjectSnapshot, -): boolean { - return ( - left !== undefined && - left.id === right.id && - left.environmentId === right.environmentId && - left.name === right.name && - left.cwd === right.cwd && - left.repositoryIdentity === right.repositoryIdentity && - left.defaultModelSelection === right.defaultModelSelection && - left.createdAt === right.createdAt && - left.updatedAt === right.updatedAt && - left.scripts === right.scripts && - left.projectKey === right.projectKey && - left.environmentPresence === right.environmentPresence && - refsEqual(left.memberProjectRefs, right.memberProjectRefs) && - stringsEqual(left.remoteEnvironmentLabels, right.remoteEnvironmentLabels) - ); -} interface TerminalStatusIndicator { label: "Terminal process running"; @@ -1057,14 +867,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP if (isShiftClick) { event.preventDefault(); - const projectSnapshot = sidebarTransientStore - .getState() - .projectSnapshotByKey.get(projectKey); - const orderedProjectThreadKeys = projectSnapshot + const orderedProjectThreadKeys = project ? sortThreadsForSidebar( selectSidebarThreadsForProjectRefs( useStore.getState(), - projectSnapshot.memberProjectRefs, + project.memberProjectRefs, ).filter((projectThread) => projectThread.archivedAt === null), threadSortOrder, ).map((projectThread) => @@ -1083,7 +890,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP [ clearSelection, navigateToThread, - projectKey, + project, rangeSelectTo, threadKey, threadRef, @@ -2472,407 +2279,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ); }); -function useSidebarKeyboardController(props: { - sortedProjectKeys: readonly string[]; - sidebarThreadSortSnapshots: readonly SidebarThreadSortSnapshot[]; - physicalToLogicalKey: ReadonlyMap; - expandedThreadListsByProject: ReadonlySet; - routeThreadRef: ScopedThreadRef | null; - routeThreadKey: string | null; - platform: string; - keybindings: ReturnType; - navigateToThread: (threadRef: ScopedThreadRef) => void; - sidebarThreadSortOrder: SidebarThreadSortOrder; -}) { - const { - sortedProjectKeys, - sidebarThreadSortSnapshots, - physicalToLogicalKey, - expandedThreadListsByProject, - routeThreadRef, - routeThreadKey, - platform, - keybindings, - navigateToThread, - sidebarThreadSortOrder, - } = props; - const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const sidebarThreadSnapshotByKey = useMemo( - () => - new Map( - sidebarThreadSortSnapshots.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), - [sidebarThreadSortSnapshots], - ); - const threadsByProjectKey = useMemo(() => { - const next = new Map(); - for (const thread of sidebarThreadSortSnapshots) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const existing = next.get(logicalKey); - if (existing) { - existing.push(thread); - } else { - next.set(logicalKey, [thread]); - } - } - return next; - }, [physicalToLogicalKey, sidebarThreadSortSnapshots]); - const visibleSidebarThreadKeys = useMemo( - () => - sortedProjectKeys.flatMap((projectKey) => { - const projectThreads = sortThreadsForSidebar( - (threadsByProjectKey.get(projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, - ), - sidebarThreadSortOrder, - ); - const projectExpanded = projectExpandedById[projectKey] ?? true; - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); - const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - }), - [ - expandedThreadListsByProject, - projectExpandedById, - routeThreadKey, - sidebarThreadSortOrder, - sortedProjectKeys, - threadsByProjectKey, - ], - ); - const threadJumpCommandByKey = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - return mapping; - } - mapping.set(threadKey, jumpCommand); - } - - return mapping; - }, [visibleSidebarThreadKeys]); - const threadJumpThreadKeys = useMemo( - () => [...threadJumpCommandByKey.keys()], - [threadJumpCommandByKey], - ); - const getCurrentSidebarShortcutContext = useCallback( - () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - }), - [routeThreadRef], - ); - const threadJumpLabelByKey = useMemo( - () => - showThreadJumpHints - ? buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: getCurrentSidebarShortcutContext().terminalOpen, - threadJumpCommandByKey, - }) - : EMPTY_THREAD_JUMP_LABELS, - [ - getCurrentSidebarShortcutContext, - keybindings, - platform, - showThreadJumpHints, - threadJumpCommandByKey, - ], - ); - const threadJumpLabelsRef = useRef>(threadJumpLabelByKey); - threadJumpLabelsRef.current = threadJumpLabelByKey; - const showThreadJumpHintsRef = useRef(showThreadJumpHints); - showThreadJumpHintsRef.current = showThreadJumpHints; - - useEffect(() => { - const clearThreadJumpHints = () => { - updateThreadJumpHintsVisibility(false); - }; - const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - event.key !== "Meta" && - event.key !== "Control" && - event.key !== "Alt" && - event.key !== "Shift" && - !showThreadJumpHintsRef.current && - threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; - - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - if ( - showThreadJumpHintsRef.current || - threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS - ) { - clearThreadJumpHints(); - } - } else { - updateThreadJumpHintsVisibility(true); - } - - if (event.defaultPrevented || event.repeat) { - return; - } - - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, - }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: visibleSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return; - } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - }; - - const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - clearThreadJumpHints(); - return; - } - updateThreadJumpHintsVisibility(true); - }; - - const onWindowBlur = () => { - clearThreadJumpHints(); - }; - - window.addEventListener("keydown", onWindowKeyDown); - window.addEventListener("keyup", onWindowKeyUp); - window.addEventListener("blur", onWindowBlur); - - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); - }; - }, [ - getCurrentSidebarShortcutContext, - keybindings, - navigateToThread, - platform, - routeThreadKey, - sidebarThreadSnapshotByKey, - threadJumpCommandByKey, - threadJumpThreadKeys, - updateThreadJumpHintsVisibility, - visibleSidebarThreadKeys, - ]); - - return threadJumpLabelByKey; -} - -const SidebarProjectOrderingController = memo(function SidebarProjectOrderingController(props: { - sidebarProjects: readonly SidebarProjectSnapshot[]; - physicalToLogicalKey: ReadonlyMap; - sidebarProjectSortOrder: SidebarProjectSortOrder; -}) { - const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; - const sidebarThreadSortSnapshots = useStore( - useMemo(() => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(), []), - ); - const sortedProjectKeys = useMemo(() => { - if (sidebarProjectSortOrder === "manual") { - return sidebarProjects.map((project) => project.projectKey); - } - - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - const sortableThreads = sidebarThreadSortSnapshots - .filter((thread) => thread.archivedAt === null) - .map((thread) => { - const physicalKey = scopedProjectKey( - scopeProjectRef(thread.environmentId, thread.projectId), - ); - return { - id: thread.id, - environmentId: thread.environmentId, - projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestUserMessageAt: thread.latestUserMessageAt, - }; - }); - return sortProjectsForSidebar(sortableProjects, sortableThreads, sidebarProjectSortOrder).map( - (project) => project.id, - ); - }, [sidebarProjectSortOrder, sidebarProjects, sidebarThreadSortSnapshots, physicalToLogicalKey]); - - useEffect(() => { - const currentState = sidebarTransientStore.getState(); - if (stringsEqual(currentState.sortedProjectKeys, sortedProjectKeys)) { - return; - } - - sidebarTransientStore.setState({ - sortedProjectKeys, - }); - }, [sortedProjectKeys]); - - return null; -}); - -const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { - navigateToThread: (threadRef: ScopedThreadRef) => void; - sidebarThreadSortOrder: SidebarThreadSortOrder; -}) { - const { navigateToThread, sidebarThreadSortOrder } = props; - const sortedProjectKeys = useSidebarProjectKeys(); - const physicalToLogicalKey = useSidebarPhysicalToLogicalKey(); - const expandedThreadListsByProject = useZustandStore( - sidebarTransientStore, - (state) => state.expandedThreadListsByProject, - ); - const sidebarThreadSortSnapshots = useStore( - useMemo(() => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(), []), - ); - const routeThreadRef = useParams({ - strict: false, - select: (params) => resolveThreadRouteRef(params), - }); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const activeRouteProjectKey = useMemo(() => { - if (!routeThreadRef) { - return null; - } - - const activeThread = sidebarThreadSortSnapshots.find( - (thread) => - thread.environmentId === routeThreadRef.environmentId && - thread.id === routeThreadRef.threadId, - ); - if (!activeThread) { - return null; - } - - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); - return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [physicalToLogicalKey, routeThreadRef, sidebarThreadSortSnapshots]); - const keybindings = useServerKeybindings(); - const platform = navigator.platform; - const threadJumpLabelByKey = useSidebarKeyboardController({ - sortedProjectKeys, - sidebarThreadSortSnapshots, - physicalToLogicalKey, - expandedThreadListsByProject, - routeThreadRef, - routeThreadKey, - platform, - keybindings, - navigateToThread, - sidebarThreadSortOrder, - }); - - useEffect(() => { - const currentState = sidebarTransientStore.getState(); - if ( - currentState.activeRouteThreadKey === routeThreadKey && - currentState.activeRouteProjectKey === activeRouteProjectKey && - currentState.threadJumpLabelByKey === threadJumpLabelByKey - ) { - return; - } - - sidebarTransientStore.setState({ - activeRouteThreadKey: routeThreadKey, - activeRouteProjectKey, - threadJumpLabelByKey, - }); - }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); - - return null; -}); - export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); @@ -2929,7 +2335,7 @@ export default function Sidebar() { const previousSidebarProjectSnapshotByKeyRef = useRef< ReadonlyMap - >(EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY); + >(new Map()); const sidebarProjects = useMemo(() => { // Group projects by logical key while preserving insertion order from // orderedProjects. @@ -3003,7 +2409,7 @@ export default function Sidebar() { } previousSidebarProjectSnapshotByKeyRef.current = nextSidebarProjectSnapshotByKey.size === 0 - ? EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY + ? new Map() : nextSidebarProjectSnapshotByKey; return result; }, [ @@ -3231,15 +2637,7 @@ export default function Sidebar() { const isManualProjectSorting = sidebarProjectSortOrder === "manual"; useEffect(() => { - const currentState = sidebarTransientStore.getState(); - if ( - currentState.projectSnapshotByKey === sidebarProjectByKey && - currentState.physicalToLogicalKey === physicalToLogicalKey - ) { - return; - } - - sidebarTransientStore.setState({ + syncSidebarProjectMappings({ projectSnapshotByKey: sidebarProjectByKey, physicalToLogicalKey, }); diff --git a/apps/web/src/components/sidebar/sidebarConstants.ts b/apps/web/src/components/sidebar/sidebarConstants.ts new file mode 100644 index 0000000000..69b80d55f3 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarConstants.ts @@ -0,0 +1 @@ +export const THREAD_PREVIEW_LIMIT = 6; diff --git a/apps/web/src/components/sidebar/sidebarControllers.tsx b/apps/web/src/components/sidebar/sidebarControllers.tsx new file mode 100644 index 0000000000..c098aded95 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarControllers.tsx @@ -0,0 +1,471 @@ +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; +import { useParams } from "@tanstack/react-router"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { isTerminalFocused } from "../../lib/terminalFocus"; +import { resolveThreadRouteRef } from "../../threadRoutes"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHints, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../../keybindings"; +import { selectThreadTerminalState, useTerminalStateStore } from "../../terminalStateStore"; +import { useUiStateStore } from "../../uiStateStore"; +import { + resolveAdjacentThreadId, + sortProjectsForSidebar, + sortThreadsForSidebar, + useThreadJumpHintVisibility, +} from "../Sidebar.logic"; +import { + createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector, + type SidebarThreadSortSnapshot, +} from "./sidebarSelectors"; +import { THREAD_PREVIEW_LIMIT } from "./sidebarConstants"; +import type { LogicalProjectKey } from "../../logicalProject"; +import { + setSidebarKeyboardState, + setSidebarProjectOrdering, + useSidebarExpandedThreadListsByProject, + useSidebarPhysicalToLogicalKey, + useSidebarProjectKeys, + type SidebarProjectSnapshot, +} from "./sidebarViewStore"; +import { useServerKeybindings } from "../../rpc/serverState"; +import { useStore } from "../../store"; + +const EMPTY_THREAD_JUMP_LABELS = new Map(); + +function buildThreadJumpLabelMap(input: { + keybindings: ReturnType; + platform: string; + terminalOpen: boolean; + threadJumpCommandByKey: ReadonlyMap< + string, + NonNullable> + >; +}): ReadonlyMap { + if (input.threadJumpCommandByKey.size === 0) { + return EMPTY_THREAD_JUMP_LABELS; + } + + const shortcutLabelOptions = { + platform: input.platform, + context: { + terminalFocus: false, + terminalOpen: input.terminalOpen, + }, + } as const; + const mapping = new Map(); + for (const [threadKey, command] of input.threadJumpCommandByKey) { + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(threadKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; +} + +function useSidebarKeyboardController(input: { + sortedProjectKeys: readonly LogicalProjectKey[]; + sidebarThreadSortSnapshots: readonly SidebarThreadSortSnapshot[]; + physicalToLogicalKey: ReadonlyMap; + expandedThreadListsByProject: ReadonlySet; + routeThreadRef: ScopedThreadRef | null; + routeThreadKey: string | null; + platform: string; + keybindings: ReturnType; + navigateToThread: (threadRef: ScopedThreadRef) => void; + sidebarThreadSortOrder: SidebarThreadSortOrder; +}) { + const { + sortedProjectKeys, + sidebarThreadSortSnapshots, + physicalToLogicalKey, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + sidebarThreadSortOrder, + } = input; + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const sidebarThreadSnapshotByKey = useMemo( + () => + new Map( + sidebarThreadSortSnapshots.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [sidebarThreadSortSnapshots], + ); + const threadsByProjectKey = useMemo(() => { + const next = new Map(); + for (const thread of sidebarThreadSortSnapshots) { + const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const existing = next.get(logicalKey); + if (existing) { + existing.push(thread); + } else { + next.set(logicalKey, [thread]); + } + } + return next; + }, [physicalToLogicalKey, sidebarThreadSortSnapshots]); + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjectKeys.flatMap((projectKey) => { + const projectThreads = sortThreadsForSidebar( + (threadsByProjectKey.get(projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + ); + const projectExpanded = projectExpandedById[projectKey] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeThreadKey, + ) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); + const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const previewThreads = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreads + : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + }), + [ + expandedThreadListsByProject, + projectExpandedById, + routeThreadKey, + sidebarThreadSortOrder, + sortedProjectKeys, + threadsByProjectKey, + ], + ); + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(threadKey, jumpCommand); + } + + return mapping; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }), + [routeThreadRef], + ); + const threadJumpLabelByKey = useMemo( + () => + showThreadJumpHints + ? buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: getCurrentSidebarShortcutContext().terminalOpen, + threadJumpCommandByKey, + }) + : EMPTY_THREAD_JUMP_LABELS, + [ + getCurrentSidebarShortcutContext, + keybindings, + platform, + showThreadJumpHints, + threadJumpCommandByKey, + ], + ); + const threadJumpLabelsRef = useRef>(threadJumpLabelByKey); + threadJumpLabelsRef.current = threadJumpLabelByKey; + const showThreadJumpHintsRef = useRef(showThreadJumpHints); + showThreadJumpHintsRef.current = showThreadJumpHints; + + useEffect(() => { + const clearThreadJumpHints = () => { + updateThreadJumpHintsVisibility(false); + }; + const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + event.key !== "Meta" && + event.key !== "Control" && + event.key !== "Alt" && + event.key !== "Shift" && + !showThreadJumpHintsRef.current && + threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + if ( + showThreadJumpHintsRef.current || + threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS + ) { + clearThreadJumpHints(); + } + } else { + updateThreadJumpHintsVisibility(true); + } + + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: shortcutContext, + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: visibleSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }); + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + }; + + const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + clearThreadJumpHints(); + return; + } + updateThreadJumpHintsVisibility(true); + }; + + const onWindowBlur = () => { + clearThreadJumpHints(); + }; + + window.addEventListener("keydown", onWindowKeyDown); + window.addEventListener("keyup", onWindowKeyUp); + window.addEventListener("blur", onWindowBlur); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + window.removeEventListener("keyup", onWindowKeyUp); + window.removeEventListener("blur", onWindowBlur); + }; + }, [ + getCurrentSidebarShortcutContext, + keybindings, + navigateToThread, + platform, + routeThreadKey, + sidebarThreadSnapshotByKey, + threadJumpCommandByKey, + threadJumpThreadKeys, + updateThreadJumpHintsVisibility, + visibleSidebarThreadKeys, + ]); + + return threadJumpLabelByKey; +} + +export const SidebarProjectOrderingController = memo( + function SidebarProjectOrderingController(props: { + sidebarProjects: readonly SidebarProjectSnapshot[]; + physicalToLogicalKey: ReadonlyMap; + sidebarProjectSortOrder: SidebarProjectSortOrder; + }) { + const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; + const sidebarThreadSortSnapshots = useStore( + useMemo( + () => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(sidebarProjectSortOrder), + [sidebarProjectSortOrder], + ), + ); + const sortedProjectKeys = useMemo(() => { + if (sidebarProjectSortOrder === "manual") { + return sidebarProjects.map((project) => project.projectKey); + } + + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = sidebarThreadSortSnapshots + .filter((thread) => thread.archivedAt === null) + .map((thread) => { + const physicalKey = scopedProjectKey( + scopeProjectRef(thread.environmentId, thread.projectId), + ); + return { + id: thread.id, + environmentId: thread.environmentId, + projectId: physicalToLogicalKey.get(physicalKey) ?? physicalKey, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + latestUserMessageAt: thread.latestUserMessageAt, + }; + }); + return sortProjectsForSidebar(sortableProjects, sortableThreads, sidebarProjectSortOrder).map( + (project) => project.id, + ); + }, [ + physicalToLogicalKey, + sidebarProjectSortOrder, + sidebarProjects, + sidebarThreadSortSnapshots, + ]); + + useEffect(() => { + setSidebarProjectOrdering(sortedProjectKeys); + }, [sortedProjectKeys]); + + return null; + }, +); + +export const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { + navigateToThread: (threadRef: ScopedThreadRef) => void; + sidebarThreadSortOrder: SidebarThreadSortOrder; +}) { + const { navigateToThread, sidebarThreadSortOrder } = props; + const sortedProjectKeys = useSidebarProjectKeys(); + const physicalToLogicalKey = useSidebarPhysicalToLogicalKey(); + const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); + const sidebarThreadSortSnapshots = useStore( + useMemo( + () => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(sidebarThreadSortOrder), + [sidebarThreadSortOrder], + ), + ); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const activeRouteProjectKey = useMemo(() => { + if (!routeThreadRef) { + return null; + } + + const activeThread = sidebarThreadSortSnapshots.find( + (thread) => + thread.environmentId === routeThreadRef.environmentId && + thread.id === routeThreadRef.threadId, + ); + if (!activeThread) { + return null; + } + + const physicalKey = scopedProjectKey( + scopeProjectRef(activeThread.environmentId, activeThread.projectId), + ); + return physicalToLogicalKey.get(physicalKey) ?? physicalKey; + }, [physicalToLogicalKey, routeThreadRef, sidebarThreadSortSnapshots]); + const keybindings = useServerKeybindings(); + const platform = navigator.platform; + const threadJumpLabelByKey = useSidebarKeyboardController({ + sortedProjectKeys, + sidebarThreadSortSnapshots, + physicalToLogicalKey, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + sidebarThreadSortOrder, + }); + + useEffect(() => { + setSidebarKeyboardState({ + activeRouteProjectKey, + activeRouteThreadKey: routeThreadKey, + threadJumpLabelByKey, + }); + }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); + + return null; +}); diff --git a/apps/web/src/components/sidebar/sidebarSelectors.ts b/apps/web/src/components/sidebar/sidebarSelectors.ts index e095f6057b..f91c1864c6 100644 --- a/apps/web/src/components/sidebar/sidebarSelectors.ts +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -1,10 +1,20 @@ import type { EnvironmentId, ProjectId, ScopedProjectRef, ThreadId } from "@t3tools/contracts"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; -import { sortThreadsForSidebar } from "../Sidebar.logic"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; +import { + sortThreadsForSidebar, + type ThreadStatusLatestTurnSnapshot, + type ThreadStatusSessionSnapshot, +} from "../Sidebar.logic"; import type { AppState, EnvironmentState } from "../../store"; import type { SidebarThreadSummary } from "../../types"; +import type { LogicalProjectKey } from "../../logicalProject"; export interface SidebarThreadSortSnapshot { id: ThreadId; @@ -30,8 +40,8 @@ export interface ProjectThreadStatusInput { hasPendingApprovals: boolean; hasPendingUserInput: boolean; interactionMode: SidebarThreadSummary["interactionMode"]; - latestTurn: SidebarThreadSummary["latestTurn"]; - session: SidebarThreadSummary["session"]; + latestTurn: ThreadStatusLatestTurnSnapshot | null; + session: ThreadStatusSessionSnapshot | null; } export interface SidebarThreadRowSnapshot { @@ -47,7 +57,7 @@ interface ProjectThreadRenderEntry { threadKey: string; id: ThreadId; environmentId: EnvironmentId; - projectId: ProjectId; + projectId: LogicalProjectKey; createdAt: string; archivedAt: string | null; updatedAt?: string | undefined; @@ -73,6 +83,7 @@ const EMPTY_PROJECT_RENDER_STATE: SidebarProjectRenderStateSnapshot = { function collectProjectThreadEntries( state: AppState, memberProjectRefs: readonly ScopedProjectRef[], + physicalToLogicalKey?: ReadonlyMap, ): ProjectThreadRenderEntry[] { if (memberProjectRefs.length === 0) { return []; @@ -94,7 +105,10 @@ function collectProjectThreadEntries( threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), id: summary.id, environmentId: summary.environmentId, - projectId: summary.projectId, + projectId: + physicalToLogicalKey?.get( + scopedProjectKey(scopeProjectRef(summary.environmentId, summary.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(summary.environmentId, summary.projectId)), createdAt: summary.createdAt, archivedAt: summary.archivedAt, updatedAt: summary.updatedAt, @@ -131,8 +145,20 @@ function collectProjectThreadStatusInputs( hasPendingApprovals: summary.hasPendingApprovals, hasPendingUserInput: summary.hasPendingUserInput, interactionMode: summary.interactionMode, - latestTurn: summary.latestTurn, - session: summary.session, + latestTurn: summary.latestTurn + ? { + turnId: summary.latestTurn.turnId, + startedAt: summary.latestTurn.startedAt, + completedAt: summary.latestTurn.completedAt, + } + : null, + session: summary.session + ? { + orchestrationStatus: summary.session.orchestrationStatus, + activeTurnId: summary.session.activeTurnId, + status: summary.session.status, + } + : null, }); } } @@ -151,6 +177,7 @@ function projectThreadStatusInputsEqual( left.hasPendingApprovals === right.hasPendingApprovals && left.hasPendingUserInput === right.hasPendingUserInput && left.interactionMode === right.interactionMode && + left.latestTurn?.turnId === right.latestTurn?.turnId && left.latestTurn?.startedAt === right.latestTurn?.startedAt && left.latestTurn?.completedAt === right.latestTurn?.completedAt && left.session?.orchestrationStatus === right.session?.orchestrationStatus && @@ -159,16 +186,29 @@ function projectThreadStatusInputsEqual( ); } -export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(): ( - state: AppState, -) => SidebarThreadSortSnapshot[] { +function includeUpdatedSortFields( + sortOrder: SidebarProjectSortOrder | SidebarThreadSortOrder, +): boolean { + return sortOrder === "updated_at"; +} + +export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector( + sortOrder: SidebarProjectSortOrder | SidebarThreadSortOrder, +): (state: AppState) => SidebarThreadSortSnapshot[] { let previousResult = EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS; let previousEntries = new Map(); return (state) => { + if (sortOrder === "manual") { + previousEntries = new Map(); + previousResult = EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS; + return previousResult; + } + const nextEntries = new Map(); const nextResult: SidebarThreadSortSnapshot[] = []; let changed = false; + const watchUpdatedFields = includeUpdatedSortFields(sortOrder); for (const [environmentId, environmentState] of Object.entries( state.environmentStateById, @@ -188,8 +228,9 @@ export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(): ( previousEntry.projectId === summary.projectId && previousEntry.createdAt === summary.createdAt && previousEntry.archivedAt === summary.archivedAt && - previousEntry.updatedAt === summary.updatedAt && - previousEntry.latestUserMessageAt === summary.latestUserMessageAt + (!watchUpdatedFields || + (previousEntry.updatedAt === summary.updatedAt && + previousEntry.latestUserMessageAt === summary.latestUserMessageAt)) ) { nextEntries.set(entryKey, previousEntry); nextResult.push(previousEntry); @@ -310,6 +351,7 @@ export function createSidebarProjectRenderStateSelector(input: { activeRouteThreadKey: string | null; isThreadListExpanded: boolean; memberProjectRefs: readonly ScopedProjectRef[]; + physicalToLogicalKey?: ReadonlyMap; projectExpanded: boolean; previewLimit: number; threadSortOrder: SidebarThreadSortOrder; @@ -318,9 +360,11 @@ export function createSidebarProjectRenderStateSelector(input: { return (state) => { const visibleProjectThreads = sortThreadsForSidebar( - collectProjectThreadEntries(state, input.memberProjectRefs).filter( - (thread) => thread.archivedAt === null, - ), + collectProjectThreadEntries( + state, + input.memberProjectRefs, + input.physicalToLogicalKey, + ).filter((thread) => thread.archivedAt === null), input.threadSortOrder, ); const pinnedCollapsedThread = diff --git a/apps/web/src/components/sidebar/sidebarViewStore.ts b/apps/web/src/components/sidebar/sidebarViewStore.ts new file mode 100644 index 0000000000..5821d57e9f --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarViewStore.ts @@ -0,0 +1,222 @@ +import { useCallback } from "react"; +import type { ScopedProjectRef } from "@t3tools/contracts"; +import { useStore as useZustandStore } from "zustand"; +import { createStore } from "zustand/vanilla"; +import type { LogicalProjectKey } from "../../logicalProject"; +import type { Project } from "../../types"; + +export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; + +export type SidebarProjectSnapshot = Project & { + projectKey: LogicalProjectKey; + environmentPresence: EnvironmentPresence; + memberProjectRefs: readonly ScopedProjectRef[]; + remoteEnvironmentLabels: readonly string[]; +}; + +interface SidebarTransientState { + sortedProjectKeys: readonly LogicalProjectKey[]; + projectSnapshotByKey: ReadonlyMap; + physicalToLogicalKey: ReadonlyMap; + activeRouteThreadKey: string | null; + activeRouteProjectKey: LogicalProjectKey | null; + threadJumpLabelByKey: ReadonlyMap; + expandedThreadListsByProject: ReadonlySet; +} + +const EMPTY_THREAD_JUMP_LABELS = new Map(); +const EMPTY_SIDEBAR_PROJECT_KEYS: LogicalProjectKey[] = []; +const EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY = new Map(); +const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); + +const sidebarViewStore = createStore(() => ({ + sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, + projectSnapshotByKey: EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY, + physicalToLogicalKey: new Map(), + activeRouteThreadKey: null, + activeRouteProjectKey: null, + threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, + expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, +})); + +function refsEqual(left: readonly ScopedProjectRef[], right: readonly ScopedProjectRef[]): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.projectId === right[index]?.projectId, + ) + ); +} + +export function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +export function sidebarProjectSnapshotsEqual( + left: SidebarProjectSnapshot | undefined, + right: SidebarProjectSnapshot, +): boolean { + return ( + left !== undefined && + left.id === right.id && + left.environmentId === right.environmentId && + left.name === right.name && + left.cwd === right.cwd && + left.repositoryIdentity === right.repositoryIdentity && + left.defaultModelSelection === right.defaultModelSelection && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + left.scripts === right.scripts && + left.projectKey === right.projectKey && + left.environmentPresence === right.environmentPresence && + refsEqual(left.memberProjectRefs, right.memberProjectRefs) && + stringArraysEqual(left.remoteEnvironmentLabels, right.remoteEnvironmentLabels) + ); +} + +export function useSidebarProjectKeys(): readonly LogicalProjectKey[] { + return useZustandStore(sidebarViewStore, (state) => state.sortedProjectKeys); +} + +export function useSidebarPhysicalToLogicalKey(): ReadonlyMap { + return useZustandStore(sidebarViewStore, (state) => state.physicalToLogicalKey); +} + +export function useSidebarProjectSnapshot( + projectKey: LogicalProjectKey, +): SidebarProjectSnapshot | null { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => state.projectSnapshotByKey.get(projectKey) ?? null, + [projectKey], + ), + ); +} + +export function useSidebarProjectThreadListExpanded(projectKey: LogicalProjectKey): boolean { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => state.expandedThreadListsByProject.has(projectKey), + [projectKey], + ), + ); +} + +export function useSidebarExpandedThreadListsByProject(): ReadonlySet { + return useZustandStore(sidebarViewStore, (state) => state.expandedThreadListsByProject); +} + +export function expandSidebarProjectThreadList(projectKey: LogicalProjectKey): void { + const { expandedThreadListsByProject } = sidebarViewStore.getState(); + if (expandedThreadListsByProject.has(projectKey)) { + return; + } + + sidebarViewStore.setState({ + expandedThreadListsByProject: new Set([...expandedThreadListsByProject, projectKey]), + }); +} + +export function collapseSidebarProjectThreadList(projectKey: LogicalProjectKey): void { + const { expandedThreadListsByProject } = sidebarViewStore.getState(); + if (!expandedThreadListsByProject.has(projectKey)) { + return; + } + + const nextExpandedThreadListsByProject = new Set(expandedThreadListsByProject); + nextExpandedThreadListsByProject.delete(projectKey); + sidebarViewStore.setState({ + expandedThreadListsByProject: + nextExpandedThreadListsByProject.size === 0 + ? EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT + : nextExpandedThreadListsByProject, + }); +} + +export function useSidebarIsActiveThread(threadKey: string): boolean { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => state.activeRouteThreadKey === threadKey, + [threadKey], + ), + ); +} + +export function useSidebarThreadJumpLabel(threadKey: string): string | null { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => state.threadJumpLabelByKey.get(threadKey) ?? null, + [threadKey], + ), + ); +} + +export function useSidebarProjectActiveRouteThreadKey( + projectKey: LogicalProjectKey, +): string | null { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => { + return state.activeRouteProjectKey === projectKey ? state.activeRouteThreadKey : null; + }, + [projectKey], + ), + ); +} + +export function syncSidebarProjectMappings(input: { + projectSnapshotByKey: ReadonlyMap; + physicalToLogicalKey: ReadonlyMap; +}): void { + const currentState = sidebarViewStore.getState(); + if ( + currentState.projectSnapshotByKey === input.projectSnapshotByKey && + currentState.physicalToLogicalKey === input.physicalToLogicalKey + ) { + return; + } + + sidebarViewStore.setState({ + projectSnapshotByKey: input.projectSnapshotByKey, + physicalToLogicalKey: input.physicalToLogicalKey, + }); +} + +export function setSidebarProjectOrdering(sortedProjectKeys: readonly LogicalProjectKey[]): void { + const currentState = sidebarViewStore.getState(); + if (stringArraysEqual(currentState.sortedProjectKeys, sortedProjectKeys)) { + return; + } + + sidebarViewStore.setState({ + sortedProjectKeys, + }); +} + +export function setSidebarKeyboardState(input: { + activeRouteProjectKey: LogicalProjectKey | null; + activeRouteThreadKey: string | null; + threadJumpLabelByKey: ReadonlyMap; +}): void { + const currentState = sidebarViewStore.getState(); + if ( + currentState.activeRouteThreadKey === input.activeRouteThreadKey && + currentState.activeRouteProjectKey === input.activeRouteProjectKey && + currentState.threadJumpLabelByKey === input.threadJumpLabelByKey + ) { + return; + } + + sidebarViewStore.setState({ + activeRouteThreadKey: input.activeRouteThreadKey, + activeRouteProjectKey: input.activeRouteProjectKey, + threadJumpLabelByKey: input.threadJumpLabelByKey, + }); +} diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 789441877b..3d4889b838 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -2,9 +2,11 @@ import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; import type { ScopedProjectRef } from "@t3tools/contracts"; import type { Project } from "./types"; +export type LogicalProjectKey = string; + export function deriveLogicalProjectKey( project: Pick, -): string { +): LogicalProjectKey { return ( project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) @@ -14,6 +16,6 @@ export function deriveLogicalProjectKey( export function deriveLogicalProjectKeyFromRef( projectRef: ScopedProjectRef, project: Pick | null | undefined, -): string { +): LogicalProjectKey { return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); } From 4d98672a420a5fa0a761e15b757fa817c9e0da79 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:10:04 +0100 Subject: [PATCH 03/21] Finalize sidebar cleanup and selector narrowing --- apps/web/src/components/Sidebar.logic.ts | 2 +- apps/web/src/components/Sidebar.tsx | 428 +++++++----------- .../components/sidebar/sidebarControllers.tsx | 46 +- .../sidebar/sidebarProjectSnapshots.ts | 133 ++++++ .../components/sidebar/sidebarSelectors.ts | 85 +++- 5 files changed, 405 insertions(+), 289 deletions(-) create mode 100644 apps/web/src/components/sidebar/sidebarProjectSnapshots.ts diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 30bd2d3f6e..8ccda47dd5 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -11,7 +11,7 @@ import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; -export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 0; +export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 75; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 565b143a0a..8b6146ad46 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -124,6 +124,7 @@ import { import { createSidebarProjectRenderStateSelector, createSidebarProjectThreadStatusInputsSelector, + createSidebarThreadMetaSnapshotSelectorByRef, createSidebarThreadRowSnapshotSelectorByRef, createSidebarThreadStatusInputSelectorByRef, type ProjectThreadStatusInput, @@ -136,7 +137,6 @@ import { import { collapseSidebarProjectThreadList, expandSidebarProjectThreadList, - sidebarProjectSnapshotsEqual, syncSidebarProjectMappings, useSidebarIsActiveThread, useSidebarProjectActiveRouteThreadKey, @@ -147,17 +147,19 @@ import { type SidebarProjectSnapshot, } from "./sidebar/sidebarViewStore"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { + buildSidebarPhysicalToLogicalKeyMap, + buildSidebarProjectSnapshots, +} from "./sidebar/sidebarProjectSnapshots"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { deriveLogicalProjectKey } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { Project } from "../types"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -195,11 +197,6 @@ function useSidebarThreadStatusInput( ); } -function useSidebarThreadRunningState(threadRef: ScopedThreadRef | null): boolean { - const statusInput = useSidebarThreadStatusInput(threadRef); - return statusInput?.session?.status === "running" && statusInput.session.activeTurnId != null; -} - function ThreadStatusLabel({ status, compact = false, @@ -292,81 +289,174 @@ function resolveThreadPr( return gitStatus.pr ?? null; } -const SidebarThreadMetaInfo = memo(function SidebarThreadMetaInfo(props: { - threadKey: string; - threadRef: ScopedThreadRef; +const SidebarThreadMetaCluster = memo(function SidebarThreadMetaCluster(props: { + appSettingsConfirmThreadArchive: boolean; + confirmArchiveButtonRef: React.RefObject; + handleArchiveImmediateClick: (event: React.MouseEvent) => void; + handleConfirmArchiveClick: (event: React.MouseEvent) => void; + handleStartArchiveConfirmation: (event: React.MouseEvent) => void; + isConfirmingArchive: boolean; isHighlighted: boolean; isRemoteThread: boolean; + stopPropagationOnPointerDown: (event: React.PointerEvent) => void; threadEnvironmentLabel: string | null; - isConfirmingArchive: boolean; + threadId: ThreadId; + threadKey: string; + threadRef: ScopedThreadRef; + threadTitle: string; }) { const { - threadKey, - threadRef, + appSettingsConfirmThreadArchive, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchive, isHighlighted, isRemoteThread, + stopPropagationOnPointerDown, threadEnvironmentLabel, - isConfirmingArchive, + threadId, + threadKey, + threadRef, + threadTitle, } = props; const jumpLabel = useSidebarThreadJumpLabel(threadKey); - const isThreadRunning = useSidebarThreadRunningState(threadRef); + const metaSnapshot = useStore( + useMemo(() => createSidebarThreadMetaSnapshotSelectorByRef(threadRef), [threadRef]), + ); + const isThreadRunning = metaSnapshot?.isRunning ?? false; const hidden = isConfirmingArchive && !isThreadRunning; - const relativeTimestamp = useStore( - useMemo( - () => (state: import("../store").AppState) => { - const thread = selectSidebarThreadSummaryByRef(state, threadRef); - return thread ? formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt) : null; - }, - [threadRef], - ), + const relativeTimestamp = useMemo( + () => (metaSnapshot ? formatRelativeTimeLabel(metaSnapshot.activityTimestamp) : null), + [metaSnapshot], ); + const isConfirmingArchiveVisible = isConfirmingArchive && !isThreadRunning; + + const archiveControl = useMemo(() => { + if (isConfirmingArchiveVisible) { + return ( + + ); + } + + if (isThreadRunning) { + return null; + } + + if (appSettingsConfirmThreadArchive) { + return ( +
+ +
+ ); + } + + return ( + + + + + } + /> + Archive + + ); + }, [ + appSettingsConfirmThreadArchive, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchiveVisible, + isThreadRunning, + stopPropagationOnPointerDown, + threadId, + threadTitle, + ]); return ( -
- + ); }); @@ -415,96 +505,6 @@ const SidebarThreadTerminalStatusIndicator = memo( }, ); -const SidebarThreadArchiveControl = memo(function SidebarThreadArchiveControl(props: { - appSettingsConfirmThreadArchive: boolean; - confirmArchiveButtonRef: React.RefObject; - handleArchiveImmediateClick: (event: React.MouseEvent) => void; - handleConfirmArchiveClick: (event: React.MouseEvent) => void; - handleStartArchiveConfirmation: (event: React.MouseEvent) => void; - isConfirmingArchive: boolean; - stopPropagationOnPointerDown: (event: React.PointerEvent) => void; - threadId: ThreadId; - threadRef: ScopedThreadRef; - threadTitle: string; -}) { - const { - appSettingsConfirmThreadArchive, - confirmArchiveButtonRef, - handleArchiveImmediateClick, - handleConfirmArchiveClick, - handleStartArchiveConfirmation, - isConfirmingArchive, - stopPropagationOnPointerDown, - threadId, - threadRef, - threadTitle, - } = props; - const isThreadRunning = useSidebarThreadRunningState(threadRef); - const isConfirmingArchiveVisible = isConfirmingArchive && !isThreadRunning; - - if (isConfirmingArchiveVisible) { - return ( - - ); - } - - if (isThreadRunning) { - return null; - } - - if (appSettingsConfirmThreadArchive) { - return ( -
- -
- ); - } - - return ( - - - - - } - /> - Archive - - ); -}); - interface SidebarThreadRowProps { threadKey: string; projectKey: string; @@ -1071,25 +1071,21 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
- -
@@ -2321,97 +2317,25 @@ export default function Sidebar() { }); }, [projectOrder, projects]); - // Build a mapping from physical project key → logical project key for - // cross-environment grouping. Projects that share a repositoryIdentity - // canonicalKey are treated as one logical project in the sidebar. - const physicalToLogicalKey = useMemo(() => { - const mapping = new Map(); - for (const project of orderedProjects) { - const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); - mapping.set(physicalKey, deriveLogicalProjectKey(project)); - } - return mapping; - }, [orderedProjects]); + const physicalToLogicalKey = useMemo( + () => buildSidebarPhysicalToLogicalKeyMap(orderedProjects), + [orderedProjects], + ); const previousSidebarProjectSnapshotByKeyRef = useRef< ReadonlyMap >(new Map()); const sidebarProjects = useMemo(() => { - // Group projects by logical key while preserving insertion order from - // orderedProjects. - const groupedMembers = new Map(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - const existing = groupedMembers.get(logicalKey); - if (existing) { - existing.push(project); - } else { - groupedMembers.set(logicalKey, [project]); - } - } - - const previousSidebarProjectSnapshotByKey = previousSidebarProjectSnapshotByKeyRef.current; - const nextSidebarProjectSnapshotByKey = new Map(); - const result: SidebarProjectSnapshot[] = []; - const seen = new Set(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - if (seen.has(logicalKey)) continue; - seen.add(logicalKey); - - const members = groupedMembers.get(logicalKey)!; - // Prefer the primary environment's project as the representative. - const representative: Project | undefined = - (primaryEnvironmentId - ? members.find((p) => p.environmentId === primaryEnvironmentId) - : undefined) ?? members[0]; - if (!representative) continue; - const hasLocal = - primaryEnvironmentId !== null && - members.some((p) => p.environmentId === primaryEnvironmentId); - const hasRemote = - primaryEnvironmentId !== null - ? members.some((p) => p.environmentId !== primaryEnvironmentId) - : false; - - const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); - const remoteLabels = members - .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) - .map((p) => { - const rt = savedEnvironmentRuntimeById[p.environmentId]; - const saved = savedEnvironmentRegistry[p.environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; - }); - const nextSnapshot: SidebarProjectSnapshot = { - id: representative.id, - environmentId: representative.environmentId, - name: representative.name, - cwd: representative.cwd, - repositoryIdentity: representative.repositoryIdentity ?? null, - defaultModelSelection: representative.defaultModelSelection, - createdAt: representative.createdAt, - updatedAt: representative.updatedAt, - scripts: representative.scripts, - projectKey: logicalKey, - environmentPresence: - hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", - memberProjectRefs: refs, - remoteEnvironmentLabels: remoteLabels, - }; - const snapshot = sidebarProjectSnapshotsEqual( - previousSidebarProjectSnapshotByKey.get(logicalKey), - nextSnapshot, - ) - ? (previousSidebarProjectSnapshotByKey.get(logicalKey) as SidebarProjectSnapshot) - : nextSnapshot; - nextSidebarProjectSnapshotByKey.set(logicalKey, snapshot); - result.push(snapshot); - } - previousSidebarProjectSnapshotByKeyRef.current = - nextSidebarProjectSnapshotByKey.size === 0 - ? new Map() - : nextSidebarProjectSnapshotByKey; - return result; + const { projectSnapshotByKey, sidebarProjects: nextSidebarProjects } = + buildSidebarProjectSnapshots({ + orderedProjects, + previousProjectSnapshotByKey: previousSidebarProjectSnapshotByKeyRef.current, + primaryEnvironmentId, + savedEnvironmentRegistryById: savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + }); + previousSidebarProjectSnapshotByKeyRef.current = projectSnapshotByKey; + return [...nextSidebarProjects]; }, [ orderedProjects, primaryEnvironmentId, diff --git a/apps/web/src/components/sidebar/sidebarControllers.tsx b/apps/web/src/components/sidebar/sidebarControllers.tsx index c098aded95..5db4454004 100644 --- a/apps/web/src/components/sidebar/sidebarControllers.tsx +++ b/apps/web/src/components/sidebar/sidebarControllers.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { + parseScopedThreadKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, @@ -27,6 +28,7 @@ import { useThreadJumpHintVisibility, } from "../Sidebar.logic"; import { + createSidebarActiveRouteProjectKeySelectorByRef, createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector, type SidebarThreadSortSnapshot, } from "./sidebarSelectors"; @@ -101,16 +103,6 @@ function useSidebarKeyboardController(input: { } = input; const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const sidebarThreadSnapshotByKey = useMemo( - () => - new Map( - sidebarThreadSortSnapshots.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), - [sidebarThreadSortSnapshots], - ); const threadsByProjectKey = useMemo(() => { const next = new Map(); for (const thread of sidebarThreadSortSnapshots) { @@ -274,14 +266,14 @@ function useSidebarKeyboardController(input: { if (!targetThreadKey) { return; } - const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + const targetThread = parseScopedThreadKey(targetThreadKey); if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + navigateToThread(targetThread); return; } @@ -294,14 +286,14 @@ function useSidebarKeyboardController(input: { if (!targetThreadKey) { return; } - const targetThread = sidebarThreadSnapshotByKey.get(targetThreadKey); + const targetThread = parseScopedThreadKey(targetThreadKey); if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + navigateToThread(targetThread); }; const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { @@ -340,7 +332,6 @@ function useSidebarKeyboardController(input: { navigateToThread, platform, routeThreadKey, - sidebarThreadSnapshotByKey, threadJumpCommandByKey, threadJumpThreadKeys, updateThreadJumpHintsVisibility, @@ -425,25 +416,12 @@ export const SidebarKeyboardController = memo(function SidebarKeyboardController select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const activeRouteProjectKey = useMemo(() => { - if (!routeThreadRef) { - return null; - } - - const activeThread = sidebarThreadSortSnapshots.find( - (thread) => - thread.environmentId === routeThreadRef.environmentId && - thread.id === routeThreadRef.threadId, - ); - if (!activeThread) { - return null; - } - - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); - return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [physicalToLogicalKey, routeThreadRef, sidebarThreadSortSnapshots]); + const activeRouteProjectKey = useStore( + useMemo( + () => createSidebarActiveRouteProjectKeySelectorByRef(routeThreadRef, physicalToLogicalKey), + [physicalToLogicalKey, routeThreadRef], + ), + ); const keybindings = useServerKeybindings(); const platform = navigator.platform; const threadJumpLabelByKey = useSidebarKeyboardController({ diff --git a/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts new file mode 100644 index 0000000000..504c1f0097 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts @@ -0,0 +1,133 @@ +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { deriveLogicalProjectKey, type LogicalProjectKey } from "../../logicalProject"; +import type { Project } from "../../types"; +import { sidebarProjectSnapshotsEqual, type SidebarProjectSnapshot } from "./sidebarViewStore"; + +type SavedEnvironmentRegistryEntry = { + label?: string | null; +} | null; + +type SavedEnvironmentRuntimeEntry = { + descriptor?: { + label?: string | null; + } | null; +} | null; + +export function buildSidebarPhysicalToLogicalKeyMap( + orderedProjects: readonly Project[], +): ReadonlyMap { + const mapping = new Map(); + for (const project of orderedProjects) { + const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); + mapping.set(physicalKey, deriveLogicalProjectKey(project)); + } + return mapping; +} + +export function buildSidebarProjectSnapshots(input: { + orderedProjects: readonly Project[]; + previousProjectSnapshotByKey: ReadonlyMap; + primaryEnvironmentId: EnvironmentId | null; + savedEnvironmentRegistryById: Readonly>; + savedEnvironmentRuntimeById: Readonly>; +}): { + projectSnapshotByKey: ReadonlyMap; + sidebarProjects: readonly SidebarProjectSnapshot[]; +} { + const { + orderedProjects, + previousProjectSnapshotByKey, + primaryEnvironmentId, + savedEnvironmentRegistryById, + savedEnvironmentRuntimeById, + } = input; + + const groupedMembers = new Map(); + for (const project of orderedProjects) { + const logicalKey = deriveLogicalProjectKey(project); + const existingMembers = groupedMembers.get(logicalKey); + if (existingMembers) { + existingMembers.push(project); + continue; + } + groupedMembers.set(logicalKey, [project]); + } + + const nextProjectSnapshotByKey = new Map(); + const sidebarProjects: SidebarProjectSnapshot[] = []; + const emittedProjectKeys = new Set(); + + for (const project of orderedProjects) { + const logicalKey = deriveLogicalProjectKey(project); + if (emittedProjectKeys.has(logicalKey)) { + continue; + } + emittedProjectKeys.add(logicalKey); + + const members = groupedMembers.get(logicalKey); + if (!members || members.length === 0) { + continue; + } + + const representative = + (primaryEnvironmentId + ? members.find((member) => member.environmentId === primaryEnvironmentId) + : undefined) ?? members[0]; + if (!representative) { + continue; + } + + const hasLocal = + primaryEnvironmentId !== null && + members.some((member) => member.environmentId === primaryEnvironmentId); + const hasRemote = + primaryEnvironmentId !== null + ? members.some((member) => member.environmentId !== primaryEnvironmentId) + : false; + + const nextSnapshot: SidebarProjectSnapshot = { + id: representative.id, + environmentId: representative.environmentId, + name: representative.name, + cwd: representative.cwd, + repositoryIdentity: representative.repositoryIdentity ?? null, + defaultModelSelection: representative.defaultModelSelection, + createdAt: representative.createdAt, + updatedAt: representative.updatedAt, + scripts: representative.scripts, + projectKey: logicalKey, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), + remoteEnvironmentLabels: members + .filter( + (member) => + primaryEnvironmentId !== null && member.environmentId !== primaryEnvironmentId, + ) + .map((member) => { + const runtimeEnvironment = savedEnvironmentRuntimeById[member.environmentId]; + const savedEnvironment = savedEnvironmentRegistryById[member.environmentId]; + return ( + runtimeEnvironment?.descriptor?.label ?? savedEnvironment?.label ?? member.environmentId + ); + }), + }; + + const cachedSnapshot = previousProjectSnapshotByKey.get(logicalKey); + const snapshot = + cachedSnapshot && sidebarProjectSnapshotsEqual(cachedSnapshot, nextSnapshot) + ? cachedSnapshot + : nextSnapshot; + nextProjectSnapshotByKey.set(logicalKey, snapshot); + sidebarProjects.push(snapshot); + } + + return { + projectSnapshotByKey: + nextProjectSnapshotByKey.size === 0 + ? new Map() + : nextProjectSnapshotByKey, + sidebarProjects, + }; +} diff --git a/apps/web/src/components/sidebar/sidebarSelectors.ts b/apps/web/src/components/sidebar/sidebarSelectors.ts index f91c1864c6..ac6c903715 100644 --- a/apps/web/src/components/sidebar/sidebarSelectors.ts +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -53,6 +53,11 @@ export interface SidebarThreadRowSnapshot { worktreePath: string | null; } +export interface SidebarThreadMetaSnapshot { + activityTimestamp: string; + isRunning: boolean; +} + interface ProjectThreadRenderEntry { threadKey: string; id: ThreadId; @@ -334,8 +339,20 @@ export function createSidebarThreadStatusInputSelectorByRef( hasPendingApprovals: summary.hasPendingApprovals, hasPendingUserInput: summary.hasPendingUserInput, interactionMode: summary.interactionMode, - latestTurn: summary.latestTurn, - session: summary.session, + latestTurn: summary.latestTurn + ? { + turnId: summary.latestTurn.turnId, + startedAt: summary.latestTurn.startedAt, + completedAt: summary.latestTurn.completedAt, + } + : null, + session: summary.session + ? { + orchestrationStatus: summary.session.orchestrationStatus, + activeTurnId: summary.session.activeTurnId, + status: summary.session.status, + } + : null, }; if (projectThreadStatusInputsEqual(previousResult, nextResult)) { @@ -347,6 +364,70 @@ export function createSidebarThreadStatusInputSelectorByRef( }; } +export function createSidebarThreadMetaSnapshotSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => SidebarThreadMetaSnapshot | undefined { + let previousResult: SidebarThreadMetaSnapshot | undefined; + + return (state) => { + if (!ref) { + return undefined; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + return undefined; + } + + const nextResult: SidebarThreadMetaSnapshot = { + activityTimestamp: summary.updatedAt ?? summary.createdAt, + isRunning: summary.session?.status === "running" && summary.session.activeTurnId != null, + }; + + if ( + previousResult && + previousResult.activityTimestamp === nextResult.activityTimestamp && + previousResult.isRunning === nextResult.isRunning + ) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarActiveRouteProjectKeySelectorByRef( + ref: ScopedThreadRef | null | undefined, + physicalToLogicalKey: ReadonlyMap, +): (state: AppState) => LogicalProjectKey | null { + let previousResult: LogicalProjectKey | null = null; + + return (state) => { + if (!ref) { + previousResult = null; + return null; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + previousResult = null; + return null; + } + + const physicalKey = scopedProjectKey(scopeProjectRef(summary.environmentId, summary.projectId)); + const nextResult = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + if (previousResult === nextResult) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + export function createSidebarProjectRenderStateSelector(input: { activeRouteThreadKey: string | null; isThreadListExpanded: boolean; From 7f2405b485291024507eb62f183dd881b6f59f09 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:30:30 +0100 Subject: [PATCH 04/21] Harden sidebar controllers and view state --- apps/web/src/components/Sidebar.tsx | 159 ++++++++-------- .../components/sidebar/sidebarControllers.tsx | 177 ++++++++---------- .../sidebar/sidebarProjectSnapshots.ts | 85 ++++++++- .../components/sidebar/sidebarSelectors.ts | 116 ++++++++++-- .../components/sidebar/sidebarViewStore.ts | 89 +-------- 5 files changed, 349 insertions(+), 277 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b6146ad46..53c7c7af6b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -137,19 +137,18 @@ import { import { collapseSidebarProjectThreadList, expandSidebarProjectThreadList, - syncSidebarProjectMappings, + resetSidebarViewState, useSidebarIsActiveThread, useSidebarProjectActiveRouteThreadKey, useSidebarProjectKeys, - useSidebarProjectSnapshot, useSidebarProjectThreadListExpanded, useSidebarThreadJumpLabel, - type SidebarProjectSnapshot, } from "./sidebar/sidebarViewStore"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { buildSidebarPhysicalToLogicalKeyMap, buildSidebarProjectSnapshots, + type SidebarProjectSnapshot, } from "./sidebar/sidebarProjectSnapshots"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; @@ -507,13 +506,12 @@ const SidebarThreadTerminalStatusIndicator = memo( interface SidebarThreadRowProps { threadKey: string; - projectKey: string; + project: SidebarProjectSnapshot; } const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { - const { threadKey, projectKey } = props; + const { threadKey, project } = props; const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); - const project = useSidebarProjectSnapshot(projectKey); const threadSortOrder = useSettings( (settings) => settings.sidebarThreadSortOrder, ); @@ -1095,7 +1093,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }); interface SidebarProjectThreadListProps { - projectKey: string; + project: SidebarProjectSnapshot; projectExpanded: boolean; hasOverflowingThreads: boolean; hiddenThreadKeys: readonly string[]; @@ -1110,7 +1108,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( props: SidebarProjectThreadListProps, ) { const { - projectKey, + project, projectExpanded, hasOverflowingThreads, hiddenThreadKeys, @@ -1140,7 +1138,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( ) : null} {shouldShowThreadPanel && renderedThreadKeys.map((threadKey) => { - return ; + return ; })} {projectExpanded && hasOverflowingThreads && !isThreadListExpanded && ( @@ -1151,12 +1149,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( size="sm" className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" onClick={() => { - expandSidebarProjectThreadList(projectKey); + expandSidebarProjectThreadList(project.projectKey); }} > Show more @@ -1172,7 +1170,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( size="sm" className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" onClick={() => { - collapseSidebarProjectThreadList(projectKey); + collapseSidebarProjectThreadList(project.projectKey); }} > Show less @@ -1325,11 +1323,10 @@ const SidebarProjectHeaderStatusIndicator = memo( const SidebarProjectOverflowStatusLabel = memo(function SidebarProjectOverflowStatusLabel(props: { hiddenThreadKeys: readonly string[]; - projectKey: string; + project: SidebarProjectSnapshot; }) { - const { hiddenThreadKeys, projectKey } = props; - const project = useSidebarProjectSnapshot(projectKey); - if (!project || hiddenThreadKeys.length === 0) { + const { hiddenThreadKeys, project } = props; + if (hiddenThreadKeys.length === 0) { return null; } const statusInputs = useSidebarProjectStatusInputs(project); @@ -1348,23 +1345,19 @@ const SidebarProjectOverflowStatusLabel = memo(function SidebarProjectOverflowSt }); interface SidebarProjectThreadSectionProps { - projectKey: string; + project: SidebarProjectSnapshot; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; } const SidebarProjectThreadSection = memo(function SidebarProjectThreadSection( props: SidebarProjectThreadSectionProps, ) { - const { projectKey, attachThreadListAutoAnimateRef } = props; - const project = useSidebarProjectSnapshot(projectKey); - const isThreadListExpanded = useSidebarProjectThreadListExpanded(projectKey); + const { project, attachThreadListAutoAnimateRef } = props; + const isThreadListExpanded = useSidebarProjectThreadListExpanded(project.projectKey); const threadSortOrder = useSettings( (settings) => settings.sidebarThreadSortOrder, ); - if (!project) { - return null; - } - const activeRouteThreadKey = useSidebarProjectActiveRouteThreadKey(projectKey); + const activeRouteThreadKey = useSidebarProjectActiveRouteThreadKey(project.projectKey); const projectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); @@ -1384,7 +1377,7 @@ const SidebarProjectThreadSection = memo(function SidebarProjectThreadSection( return ( ; suppressProjectClickAfterDragRef: React.RefObject; suppressProjectClickForContextMenuRef: React.RefObject; @@ -1408,14 +1401,13 @@ interface SidebarProjectHeaderProps { const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarProjectHeaderProps) { const { - projectKey, + project, dragInProgressRef, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, isManualProjectSorting, dragHandleProps, } = props; - const project = useSidebarProjectSnapshot(projectKey); const defaultThreadEnvMode = useSettings( (settings) => settings.defaultThreadEnvMode, ); @@ -1451,7 +1443,9 @@ const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarPr }); }, }); - const projectExpanded = useUiStateStore((state) => state.projectExpandedById[projectKey] ?? true); + const projectExpanded = useUiStateStore( + (state) => state.projectExpandedById[project.projectKey] ?? true, + ); const projectThreadCount = useSidebarProjectThreadCount(project); const newThreadShortcutLabelOptions = useMemo( () => ({ @@ -1466,10 +1460,6 @@ const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarPr const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); - if (!project) { - return null; - } - const handleProjectButtonClick = useCallback( (event: React.MouseEvent) => { if (suppressProjectClickForContextMenuRef.current) { @@ -1492,12 +1482,12 @@ const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarPr if (selectedThreadCount > 0) { clearSelection(); } - toggleProject(projectKey); + toggleProject(project.projectKey); }, [ clearSelection, dragInProgressRef, - projectKey, + project.projectKey, selectedThreadCount, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, @@ -1512,9 +1502,9 @@ const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarPr if (dragInProgressRef.current) { return; } - toggleProject(projectKey); + toggleProject(project.projectKey); }, - [dragInProgressRef, projectKey, toggleProject], + [dragInProgressRef, project.projectKey, toggleProject], ); const handleProjectButtonPointerDownCapture = useCallback( @@ -1736,7 +1726,7 @@ const SidebarProjectHeader = memo(function SidebarProjectHeader(props: SidebarPr }); interface SidebarProjectItemProps { - projectKey: string; + project: SidebarProjectSnapshot; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; dragInProgressRef: React.RefObject; suppressProjectClickAfterDragRef: React.RefObject; @@ -1747,7 +1737,7 @@ interface SidebarProjectItemProps { const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { const { - projectKey, + project, attachThreadListAutoAnimateRef, dragInProgressRef, suppressProjectClickAfterDragRef, @@ -1759,7 +1749,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return ( <> @@ -1976,6 +1966,7 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { }); interface SidebarProjectsContentProps { + sidebarProjectByKey: ReadonlyMap; showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; desktopUpdateButtonAction: "download" | "install" | "none"; @@ -2016,6 +2007,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( props: SidebarProjectsContentProps, ) { const { + sidebarProjectByKey, showArm64IntelBuildWarning, arm64IntelBuildWarningDescription, desktopUpdateButtonAction, @@ -2228,40 +2220,56 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( items={[...sortedProjectKeys]} strategy={verticalListSortingStrategy} > - {sortedProjectKeys.map((projectKey) => ( - - {(dragHandleProps) => ( - - )} - - ))} + {sortedProjectKeys.map((projectKey) => + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; + } + return ( + + {(dragHandleProps) => ( + + )} + + ); + })(), + )} ) : ( - {sortedProjectKeys.map((projectKey) => ( - - ))} + {sortedProjectKeys.map((projectKey) => + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; + } + return ( + + ); + })(), + )} )} @@ -2561,11 +2569,10 @@ export default function Sidebar() { const isManualProjectSorting = sidebarProjectSortOrder === "manual"; useEffect(() => { - syncSidebarProjectMappings({ - projectSnapshotByKey: sidebarProjectByKey, - physicalToLogicalKey, - }); - }, [physicalToLogicalKey, sidebarProjectByKey]); + return () => { + resetSidebarViewState(); + }; + }, []); useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { @@ -2701,6 +2708,7 @@ export default function Sidebar() { /> @@ -2709,6 +2717,7 @@ export default function Sidebar() { ) : ( <> ; + sortedThreadKeysByLogicalProject: ReadonlyMap; expandedThreadListsByProject: ReadonlySet; routeThreadRef: ScopedThreadRef | null; routeThreadKey: string | null; platform: string; keybindings: ReturnType; navigateToThread: (threadRef: ScopedThreadRef) => void; - sidebarThreadSortOrder: SidebarThreadSortOrder; }) { const { sortedProjectKeys, - sidebarThreadSortSnapshots, - physicalToLogicalKey, + sortedThreadKeysByLogicalProject, expandedThreadListsByProject, routeThreadRef, routeThreadKey, platform, keybindings, navigateToThread, - sidebarThreadSortOrder, } = input; const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const threadsByProjectKey = useMemo(() => { - const next = new Map(); - for (const thread of sidebarThreadSortSnapshots) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const existing = next.get(logicalKey); - if (existing) { - existing.push(thread); - } else { - next.set(logicalKey, [thread]); - } - } - return next; - }, [physicalToLogicalKey, sidebarThreadSortSnapshots]); const visibleSidebarThreadKeys = useMemo( () => sortedProjectKeys.flatMap((projectKey) => { - const projectThreads = sortThreadsForSidebar( - (threadsByProjectKey.get(projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, - ), - sidebarThreadSortOrder, - ); + const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; const projectExpanded = projectExpandedById[projectKey] ?? true; const activeThreadKey = routeThreadKey ?? undefined; const pinnedCollapsedThread = !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) + ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) : null; const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; if (!shouldShowThreadPanel) { return []; } const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); - const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; const previewThreads = isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); + ? projectThreadKeys + : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); + return pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; }), [ expandedThreadListsByProject, projectExpandedById, routeThreadKey, - sidebarThreadSortOrder, sortedProjectKeys, - threadsByProjectKey, + sortedThreadKeysByLogicalProject, ], ); const threadJumpCommandByKey = useMemo(() => { @@ -210,10 +171,30 @@ function useSidebarKeyboardController(input: { threadJumpLabelsRef.current = threadJumpLabelByKey; const showThreadJumpHintsRef = useRef(showThreadJumpHints); showThreadJumpHintsRef.current = showThreadJumpHints; + const latestKeyboardStateRef = useRef({ + keybindings, + navigateToThread, + platform, + routeThreadKey, + routeThreadRef, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + }); + latestKeyboardStateRef.current = { + keybindings, + navigateToThread, + platform, + routeThreadKey, + routeThreadRef, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + }; + const updateThreadJumpHintsVisibilityRef = useRef(updateThreadJumpHintsVisibility); + updateThreadJumpHintsVisibilityRef.current = updateThreadJumpHintsVisibility; useEffect(() => { const clearThreadJumpHints = () => { - updateThreadJumpHintsVisibility(false); + updateThreadJumpHintsVisibilityRef.current(false); }; const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => !event.metaKey && @@ -226,12 +207,32 @@ function useSidebarKeyboardController(input: { event.key !== "Shift" && !showThreadJumpHintsRef.current && threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + const getCurrentSidebarShortcutContext = () => { + const { routeThreadRef } = latestKeyboardStateRef.current; + return { + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }; + }; const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { if (shouldIgnoreThreadJumpHintUpdate(event)) { return; } + const { + keybindings, + navigateToThread, + platform, + routeThreadKey, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + } = latestKeyboardStateRef.current; const shortcutContext = getCurrentSidebarShortcutContext(); const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { platform, @@ -245,7 +246,7 @@ function useSidebarKeyboardController(input: { clearThreadJumpHints(); } } else { - updateThreadJumpHintsVisibility(true); + updateThreadJumpHintsVisibilityRef.current(true); } if (event.defaultPrevented || event.repeat) { @@ -301,6 +302,7 @@ function useSidebarKeyboardController(input: { return; } + const { keybindings, platform } = latestKeyboardStateRef.current; const shortcutContext = getCurrentSidebarShortcutContext(); const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { platform, @@ -310,7 +312,7 @@ function useSidebarKeyboardController(input: { clearThreadJumpHints(); return; } - updateThreadJumpHintsVisibility(true); + updateThreadJumpHintsVisibilityRef.current(true); }; const onWindowBlur = () => { @@ -326,17 +328,7 @@ function useSidebarKeyboardController(input: { window.removeEventListener("keyup", onWindowKeyUp); window.removeEventListener("blur", onWindowBlur); }; - }, [ - getCurrentSidebarShortcutContext, - keybindings, - navigateToThread, - platform, - routeThreadKey, - threadJumpCommandByKey, - threadJumpThreadKeys, - updateThreadJumpHintsVisibility, - visibleSidebarThreadKeys, - ]); + }, []); return threadJumpLabelByKey; } @@ -348,10 +340,14 @@ export const SidebarProjectOrderingController = memo( sidebarProjectSortOrder: SidebarProjectSortOrder; }) { const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; - const sidebarThreadSortSnapshots = useStore( + const orderingThreads = useStore( useMemo( - () => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(sidebarProjectSortOrder), - [sidebarProjectSortOrder], + () => + createSidebarProjectOrderingThreadSnapshotsSelector({ + physicalToLogicalKey, + sortOrder: sidebarProjectSortOrder, + }), + [physicalToLogicalKey, sidebarProjectSortOrder], ), ); const sortedProjectKeys = useMemo(() => { @@ -363,31 +359,10 @@ export const SidebarProjectOrderingController = memo( ...project, id: project.projectKey, })); - const sortableThreads = sidebarThreadSortSnapshots - .filter((thread) => thread.archivedAt === null) - .map((thread) => { - const physicalKey = scopedProjectKey( - scopeProjectRef(thread.environmentId, thread.projectId), - ); - return { - id: thread.id, - environmentId: thread.environmentId, - projectId: physicalToLogicalKey.get(physicalKey) ?? physicalKey, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestUserMessageAt: thread.latestUserMessageAt, - }; - }); - return sortProjectsForSidebar(sortableProjects, sortableThreads, sidebarProjectSortOrder).map( + return sortProjectsForSidebar(sortableProjects, orderingThreads, sidebarProjectSortOrder).map( (project) => project.id, ); - }, [ - physicalToLogicalKey, - sidebarProjectSortOrder, - sidebarProjects, - sidebarThreadSortSnapshots, - ]); + }, [orderingThreads, sidebarProjectSortOrder, sidebarProjects]); useEffect(() => { setSidebarProjectOrdering(sortedProjectKeys); @@ -399,16 +374,20 @@ export const SidebarProjectOrderingController = memo( export const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { navigateToThread: (threadRef: ScopedThreadRef) => void; + physicalToLogicalKey: ReadonlyMap; sidebarThreadSortOrder: SidebarThreadSortOrder; }) { - const { navigateToThread, sidebarThreadSortOrder } = props; + const { navigateToThread, physicalToLogicalKey, sidebarThreadSortOrder } = props; const sortedProjectKeys = useSidebarProjectKeys(); - const physicalToLogicalKey = useSidebarPhysicalToLogicalKey(); const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); - const sidebarThreadSortSnapshots = useStore( + const sortedThreadKeysByLogicalProject = useStore( useMemo( - () => createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector(sidebarThreadSortOrder), - [sidebarThreadSortOrder], + () => + createSidebarSortedThreadKeysByLogicalProjectSelector({ + physicalToLogicalKey, + threadSortOrder: sidebarThreadSortOrder, + }), + [physicalToLogicalKey, sidebarThreadSortOrder], ), ); const routeThreadRef = useParams({ @@ -426,15 +405,13 @@ export const SidebarKeyboardController = memo(function SidebarKeyboardController const platform = navigator.platform; const threadJumpLabelByKey = useSidebarKeyboardController({ sortedProjectKeys, - sidebarThreadSortSnapshots, - physicalToLogicalKey, + sortedThreadKeysByLogicalProject, expandedThreadListsByProject, routeThreadRef, routeThreadKey, platform, keybindings, navigateToThread, - sidebarThreadSortOrder, }); useEffect(() => { diff --git a/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts index 504c1f0097..4e2f57eae5 100644 --- a/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts +++ b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts @@ -1,8 +1,17 @@ +import type { ModelSelection, ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import { deriveLogicalProjectKey, type LogicalProjectKey } from "../../logicalProject"; -import type { Project } from "../../types"; -import { sidebarProjectSnapshotsEqual, type SidebarProjectSnapshot } from "./sidebarViewStore"; +import type { Project, ProjectScript } from "../../types"; + +export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; + +export type SidebarProjectSnapshot = Project & { + projectKey: LogicalProjectKey; + environmentPresence: EnvironmentPresence; + memberProjectRefs: readonly ScopedProjectRef[]; + remoteEnvironmentLabels: readonly string[]; +}; type SavedEnvironmentRegistryEntry = { label?: string | null; @@ -14,6 +23,78 @@ type SavedEnvironmentRuntimeEntry = { } | null; } | null; +function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function refsEqual(left: readonly ScopedProjectRef[], right: readonly ScopedProjectRef[]): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.projectId === right[index]?.projectId, + ) + ); +} + +function modelSelectionsEqual(left: ModelSelection | null, right: ModelSelection | null): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + + return ( + left.provider === right.provider && + left.model === right.model && + JSON.stringify(left.options ?? null) === JSON.stringify(right.options ?? null) + ); +} + +function projectScriptsEqual( + left: readonly ProjectScript[], + right: readonly ProjectScript[], +): boolean { + return ( + left.length === right.length && + left.every((script, index) => { + const other = right[index]; + return ( + other !== undefined && + script.id === other.id && + script.name === other.name && + script.command === other.command && + script.icon === other.icon && + script.runOnWorktreeCreate === other.runOnWorktreeCreate + ); + }) + ); +} + +export function sidebarProjectSnapshotsEqual( + left: SidebarProjectSnapshot | undefined, + right: SidebarProjectSnapshot, +): boolean { + return ( + left !== undefined && + left.id === right.id && + left.environmentId === right.environmentId && + left.name === right.name && + left.cwd === right.cwd && + left.repositoryIdentity === right.repositoryIdentity && + modelSelectionsEqual(left.defaultModelSelection, right.defaultModelSelection) && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + projectScriptsEqual(left.scripts, right.scripts) && + left.projectKey === right.projectKey && + left.environmentPresence === right.environmentPresence && + refsEqual(left.memberProjectRefs, right.memberProjectRefs) && + stringArraysEqual(left.remoteEnvironmentLabels, right.remoteEnvironmentLabels) + ); +} + export function buildSidebarPhysicalToLogicalKeyMap( orderedProjects: readonly Project[], ): ReadonlyMap { diff --git a/apps/web/src/components/sidebar/sidebarSelectors.ts b/apps/web/src/components/sidebar/sidebarSelectors.ts index ac6c903715..e31208e4b1 100644 --- a/apps/web/src/components/sidebar/sidebarSelectors.ts +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -16,19 +16,20 @@ import type { AppState, EnvironmentState } from "../../store"; import type { SidebarThreadSummary } from "../../types"; import type { LogicalProjectKey } from "../../logicalProject"; -export interface SidebarThreadSortSnapshot { +export interface SidebarProjectOrderingThreadSnapshot { id: ThreadId; environmentId: EnvironmentId; - projectId: ProjectId; + projectId: LogicalProjectKey; createdAt: string; archivedAt: string | null; updatedAt?: string | undefined; latestUserMessageAt: string | null; } -const EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS: SidebarThreadSortSnapshot[] = []; +const EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS: SidebarProjectOrderingThreadSnapshot[] = []; const EMPTY_PROJECT_THREAD_KEYS: string[] = []; const EMPTY_PROJECT_THREAD_STATUS_INPUTS: ProjectThreadStatusInput[] = []; +const EMPTY_SORTED_THREAD_KEYS_BY_LOGICAL_PROJECT = new Map(); function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); @@ -197,40 +198,47 @@ function includeUpdatedSortFields( return sortOrder === "updated_at"; } -export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector( - sortOrder: SidebarProjectSortOrder | SidebarThreadSortOrder, -): (state: AppState) => SidebarThreadSortSnapshot[] { - let previousResult = EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS; - let previousEntries = new Map(); +export function createSidebarProjectOrderingThreadSnapshotsSelector(input: { + physicalToLogicalKey: ReadonlyMap; + sortOrder: SidebarProjectSortOrder; +}): (state: AppState) => readonly SidebarProjectOrderingThreadSnapshot[] { + let previousResult: + | readonly SidebarProjectOrderingThreadSnapshot[] + | SidebarProjectOrderingThreadSnapshot[] = EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS; + let previousEntries = new Map(); return (state) => { - if (sortOrder === "manual") { - previousEntries = new Map(); - previousResult = EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS; + if (input.sortOrder === "manual") { + previousEntries = new Map(); + previousResult = EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS; return previousResult; } - const nextEntries = new Map(); - const nextResult: SidebarThreadSortSnapshot[] = []; + const watchUpdatedFields = includeUpdatedSortFields(input.sortOrder); + const nextEntries = new Map(); + const nextResult: SidebarProjectOrderingThreadSnapshot[] = []; let changed = false; - const watchUpdatedFields = includeUpdatedSortFields(sortOrder); for (const [environmentId, environmentState] of Object.entries( state.environmentStateById, ) as Array<[EnvironmentId, EnvironmentState]>) { for (const threadId of environmentState.threadIds) { const summary = environmentState.sidebarThreadSummaryById[threadId]; - if (!summary || summary.environmentId !== environmentId) { + if (!summary || summary.environmentId !== environmentId || summary.archivedAt !== null) { continue; } + const physicalKey = scopedProjectKey( + scopeProjectRef(summary.environmentId, summary.projectId), + ); + const logicalProjectKey = input.physicalToLogicalKey.get(physicalKey) ?? physicalKey; const entryKey = `${environmentId}:${threadId}`; const previousEntry = previousEntries.get(entryKey); if ( previousEntry && previousEntry.id === summary.id && previousEntry.environmentId === summary.environmentId && - previousEntry.projectId === summary.projectId && + previousEntry.projectId === logicalProjectKey && previousEntry.createdAt === summary.createdAt && previousEntry.archivedAt === summary.archivedAt && (!watchUpdatedFields || @@ -245,10 +253,10 @@ export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector( continue; } - const snapshot: SidebarThreadSortSnapshot = { + const snapshot: SidebarProjectOrderingThreadSnapshot = { id: summary.id, environmentId: summary.environmentId, - projectId: summary.projectId, + projectId: logicalProjectKey, createdAt: summary.createdAt, archivedAt: summary.archivedAt, updatedAt: summary.updatedAt, @@ -270,7 +278,77 @@ export function createSidebarThreadSortSnapshotsAcrossEnvironmentsSelector( } previousEntries = nextEntries; - previousResult = nextResult.length === 0 ? EMPTY_SIDEBAR_THREAD_SORT_SNAPSHOTS : nextResult; + previousResult = nextResult.length === 0 ? EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS : nextResult; + return previousResult; + }; +} + +export function createSidebarSortedThreadKeysByLogicalProjectSelector(input: { + physicalToLogicalKey: ReadonlyMap; + threadSortOrder: SidebarThreadSortOrder; +}): (state: AppState) => ReadonlyMap { + let previousResult = EMPTY_SORTED_THREAD_KEYS_BY_LOGICAL_PROJECT; + + return (state) => { + const groupedEntries = new Map(); + for (const [environmentId, environmentState] of Object.entries( + state.environmentStateById, + ) as Array<[EnvironmentId, EnvironmentState]>) { + for (const threadId of environmentState.threadIds) { + const summary = environmentState.sidebarThreadSummaryById[threadId]; + if (!summary || summary.environmentId !== environmentId || summary.archivedAt !== null) { + continue; + } + + const physicalKey = scopedProjectKey( + scopeProjectRef(summary.environmentId, summary.projectId), + ); + const logicalProjectKey = input.physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const projectEntries = groupedEntries.get(logicalProjectKey); + const entry: ProjectThreadRenderEntry = { + threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), + id: summary.id, + environmentId: summary.environmentId, + projectId: logicalProjectKey, + createdAt: summary.createdAt, + archivedAt: summary.archivedAt, + updatedAt: summary.updatedAt, + latestUserMessageAt: summary.latestUserMessageAt, + }; + if (projectEntries) { + projectEntries.push(entry); + } else { + groupedEntries.set(logicalProjectKey, [entry]); + } + } + } + + const nextResult = new Map(); + let changed = previousResult.size !== groupedEntries.size; + + for (const [projectKey, entries] of groupedEntries) { + const nextThreadKeys = sortThreadsForSidebar(entries, input.threadSortOrder).map( + (thread) => thread.threadKey, + ); + const previousThreadKeys = previousResult.get(projectKey); + if (previousThreadKeys && stringArraysEqual(previousThreadKeys, nextThreadKeys)) { + nextResult.set(projectKey, previousThreadKeys); + continue; + } + + nextResult.set( + projectKey, + nextThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : nextThreadKeys, + ); + changed = true; + } + + if (!changed) { + return previousResult; + } + + previousResult = + nextResult.size === 0 ? EMPTY_SORTED_THREAD_KEYS_BY_LOGICAL_PROJECT : nextResult; return previousResult; }; } diff --git a/apps/web/src/components/sidebar/sidebarViewStore.ts b/apps/web/src/components/sidebar/sidebarViewStore.ts index 5821d57e9f..88beb0b297 100644 --- a/apps/web/src/components/sidebar/sidebarViewStore.ts +++ b/apps/web/src/components/sidebar/sidebarViewStore.ts @@ -1,23 +1,10 @@ import { useCallback } from "react"; -import type { ScopedProjectRef } from "@t3tools/contracts"; import { useStore as useZustandStore } from "zustand"; import { createStore } from "zustand/vanilla"; import type { LogicalProjectKey } from "../../logicalProject"; -import type { Project } from "../../types"; - -export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; - -export type SidebarProjectSnapshot = Project & { - projectKey: LogicalProjectKey; - environmentPresence: EnvironmentPresence; - memberProjectRefs: readonly ScopedProjectRef[]; - remoteEnvironmentLabels: readonly string[]; -}; interface SidebarTransientState { sortedProjectKeys: readonly LogicalProjectKey[]; - projectSnapshotByKey: ReadonlyMap; - physicalToLogicalKey: ReadonlyMap; activeRouteThreadKey: string | null; activeRouteProjectKey: LogicalProjectKey | null; threadJumpLabelByKey: ReadonlyMap; @@ -26,76 +13,34 @@ interface SidebarTransientState { const EMPTY_THREAD_JUMP_LABELS = new Map(); const EMPTY_SIDEBAR_PROJECT_KEYS: LogicalProjectKey[] = []; -const EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY = new Map(); const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); const sidebarViewStore = createStore(() => ({ sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, - projectSnapshotByKey: EMPTY_SIDEBAR_PROJECT_SNAPSHOT_BY_KEY, - physicalToLogicalKey: new Map(), activeRouteThreadKey: null, activeRouteProjectKey: null, threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, })); -function refsEqual(left: readonly ScopedProjectRef[], right: readonly ScopedProjectRef[]): boolean { - return ( - left.length === right.length && - left.every( - (ref, index) => - ref.environmentId === right[index]?.environmentId && - ref.projectId === right[index]?.projectId, - ) - ); +export function resetSidebarViewState(): void { + sidebarViewStore.setState({ + sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, + activeRouteThreadKey: null, + activeRouteProjectKey: null, + threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, + expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, + }); } export function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } -export function sidebarProjectSnapshotsEqual( - left: SidebarProjectSnapshot | undefined, - right: SidebarProjectSnapshot, -): boolean { - return ( - left !== undefined && - left.id === right.id && - left.environmentId === right.environmentId && - left.name === right.name && - left.cwd === right.cwd && - left.repositoryIdentity === right.repositoryIdentity && - left.defaultModelSelection === right.defaultModelSelection && - left.createdAt === right.createdAt && - left.updatedAt === right.updatedAt && - left.scripts === right.scripts && - left.projectKey === right.projectKey && - left.environmentPresence === right.environmentPresence && - refsEqual(left.memberProjectRefs, right.memberProjectRefs) && - stringArraysEqual(left.remoteEnvironmentLabels, right.remoteEnvironmentLabels) - ); -} - export function useSidebarProjectKeys(): readonly LogicalProjectKey[] { return useZustandStore(sidebarViewStore, (state) => state.sortedProjectKeys); } -export function useSidebarPhysicalToLogicalKey(): ReadonlyMap { - return useZustandStore(sidebarViewStore, (state) => state.physicalToLogicalKey); -} - -export function useSidebarProjectSnapshot( - projectKey: LogicalProjectKey, -): SidebarProjectSnapshot | null { - return useZustandStore( - sidebarViewStore, - useCallback( - (state: SidebarTransientState) => state.projectSnapshotByKey.get(projectKey) ?? null, - [projectKey], - ), - ); -} - export function useSidebarProjectThreadListExpanded(projectKey: LogicalProjectKey): boolean { return useZustandStore( sidebarViewStore, @@ -171,24 +116,6 @@ export function useSidebarProjectActiveRouteThreadKey( ); } -export function syncSidebarProjectMappings(input: { - projectSnapshotByKey: ReadonlyMap; - physicalToLogicalKey: ReadonlyMap; -}): void { - const currentState = sidebarViewStore.getState(); - if ( - currentState.projectSnapshotByKey === input.projectSnapshotByKey && - currentState.physicalToLogicalKey === input.physicalToLogicalKey - ) { - return; - } - - sidebarViewStore.setState({ - projectSnapshotByKey: input.projectSnapshotByKey, - physicalToLogicalKey: input.physicalToLogicalKey, - }); -} - export function setSidebarProjectOrdering(sortedProjectKeys: readonly LogicalProjectKey[]): void { const currentState = sidebarViewStore.getState(); if (stringArraysEqual(currentState.sortedProjectKeys, sortedProjectKeys)) { From eb8b0476e62a980fd52bfc06f3c3a5525ac1ac39 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:43:48 +0100 Subject: [PATCH 05/21] Tighten sidebar controller subscriptions --- apps/web/src/components/Sidebar.tsx | 18 +-- .../components/sidebar/sidebarControllers.tsx | 132 +++++++++--------- .../components/sidebar/sidebarSelectors.ts | 104 ++++++++++++++ 3 files changed, 175 insertions(+), 79 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 53c7c7af6b..2a977c949c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -117,7 +117,6 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, - shouldClearThreadSelectionOnMouseDown, sortThreadsForSidebar, ThreadStatusPill, } from "./Sidebar.logic"; @@ -133,6 +132,7 @@ import { THREAD_PREVIEW_LIMIT } from "./sidebar/sidebarConstants"; import { SidebarKeyboardController, SidebarProjectOrderingController, + SidebarSelectionController, } from "./sidebar/sidebarControllers"; import { collapseSidebarProjectThreadList, @@ -2308,7 +2308,6 @@ export default function Sidebar() { const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); - const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); @@ -2574,20 +2573,6 @@ export default function Sidebar() { }; }, []); - useEffect(() => { - const onMouseDown = (event: globalThis.MouseEvent) => { - if (selectedThreadCount === 0) return; - const target = event.target instanceof HTMLElement ? event.target : null; - if (!shouldClearThreadSelectionOnMouseDown(target)) return; - clearSelection(); - }; - - window.addEventListener("mousedown", onMouseDown); - return () => { - window.removeEventListener("mousedown", onMouseDown); - }; - }, [clearSelection, selectedThreadCount]); - useEffect(() => { if (!isElectron) return; const bridge = window.desktopBridge; @@ -2706,6 +2691,7 @@ export default function Sidebar() { physicalToLogicalKey={physicalToLogicalKey} sidebarProjectSortOrder={sidebarProjectSortOrder} /> + (); @@ -70,56 +71,53 @@ function buildThreadJumpLabelMap(input: { } function useSidebarKeyboardController(input: { + physicalToLogicalKey: ReadonlyMap; sortedProjectKeys: readonly LogicalProjectKey[]; - sortedThreadKeysByLogicalProject: ReadonlyMap; expandedThreadListsByProject: ReadonlySet; routeThreadRef: ScopedThreadRef | null; routeThreadKey: string | null; platform: string; keybindings: ReturnType; navigateToThread: (threadRef: ScopedThreadRef) => void; + threadSortOrder: SidebarThreadSortOrder; }) { const { + physicalToLogicalKey, sortedProjectKeys, - sortedThreadKeysByLogicalProject, expandedThreadListsByProject, routeThreadRef, routeThreadKey, platform, keybindings, navigateToThread, + threadSortOrder, } = input; - const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const projectExpandedStates = useUiStateStore( + useShallow((store) => + sortedProjectKeys.map((projectKey) => store.projectExpandedById[projectKey] ?? true), + ), + ); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const visibleSidebarThreadKeys = useMemo( - () => - sortedProjectKeys.flatMap((projectKey) => { - const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; - const projectExpanded = projectExpandedById[projectKey] ?? true; - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); - const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreadKeys - : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); - return pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - }), - [ - expandedThreadListsByProject, - projectExpandedById, - routeThreadKey, - sortedProjectKeys, - sortedThreadKeysByLogicalProject, - ], + const visibleSidebarThreadKeys = useStore( + useMemo( + () => + createSidebarVisibleThreadKeysSelector({ + expandedThreadListsByProject, + physicalToLogicalKey, + projectExpandedStates, + routeThreadKey, + sortedProjectKeys, + threadSortOrder, + }), + [ + expandedThreadListsByProject, + physicalToLogicalKey, + projectExpandedStates, + routeThreadKey, + sortedProjectKeys, + threadSortOrder, + ], + ), ); const threadJumpCommandByKey = useMemo(() => { const mapping = new Map>>(); @@ -333,6 +331,35 @@ function useSidebarKeyboardController(input: { return threadJumpLabelByKey; } +export const SidebarSelectionController = memo(function SidebarSelectionController() { + const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const selectedThreadCountRef = useRef(selectedThreadCount); + selectedThreadCountRef.current = selectedThreadCount; + const clearSelectionRef = useRef(clearSelection); + clearSelectionRef.current = clearSelection; + + useEffect(() => { + const onMouseDown = (event: globalThis.MouseEvent) => { + if (selectedThreadCountRef.current === 0) { + return; + } + const target = event.target instanceof HTMLElement ? event.target : null; + if (!shouldClearThreadSelectionOnMouseDown(target)) { + return; + } + clearSelectionRef.current(); + }; + + window.addEventListener("mousedown", onMouseDown); + return () => { + window.removeEventListener("mousedown", onMouseDown); + }; + }, []); + + return null; +}); + export const SidebarProjectOrderingController = memo( function SidebarProjectOrderingController(props: { sidebarProjects: readonly SidebarProjectSnapshot[]; @@ -340,29 +367,17 @@ export const SidebarProjectOrderingController = memo( sidebarProjectSortOrder: SidebarProjectSortOrder; }) { const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; - const orderingThreads = useStore( + const sortedProjectKeys = useStore( useMemo( () => - createSidebarProjectOrderingThreadSnapshotsSelector({ + createSidebarSortedProjectKeysSelector({ physicalToLogicalKey, + projects: sidebarProjects, sortOrder: sidebarProjectSortOrder, }), - [physicalToLogicalKey, sidebarProjectSortOrder], + [physicalToLogicalKey, sidebarProjectSortOrder, sidebarProjects], ), ); - const sortedProjectKeys = useMemo(() => { - if (sidebarProjectSortOrder === "manual") { - return sidebarProjects.map((project) => project.projectKey); - } - - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - return sortProjectsForSidebar(sortableProjects, orderingThreads, sidebarProjectSortOrder).map( - (project) => project.id, - ); - }, [orderingThreads, sidebarProjectSortOrder, sidebarProjects]); useEffect(() => { setSidebarProjectOrdering(sortedProjectKeys); @@ -380,16 +395,6 @@ export const SidebarKeyboardController = memo(function SidebarKeyboardController const { navigateToThread, physicalToLogicalKey, sidebarThreadSortOrder } = props; const sortedProjectKeys = useSidebarProjectKeys(); const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); - const sortedThreadKeysByLogicalProject = useStore( - useMemo( - () => - createSidebarSortedThreadKeysByLogicalProjectSelector({ - physicalToLogicalKey, - threadSortOrder: sidebarThreadSortOrder, - }), - [physicalToLogicalKey, sidebarThreadSortOrder], - ), - ); const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), @@ -404,14 +409,15 @@ export const SidebarKeyboardController = memo(function SidebarKeyboardController const keybindings = useServerKeybindings(); const platform = navigator.platform; const threadJumpLabelByKey = useSidebarKeyboardController({ + physicalToLogicalKey, sortedProjectKeys, - sortedThreadKeysByLogicalProject, expandedThreadListsByProject, routeThreadRef, routeThreadKey, platform, keybindings, navigateToThread, + threadSortOrder: sidebarThreadSortOrder, }); useEffect(() => { diff --git a/apps/web/src/components/sidebar/sidebarSelectors.ts b/apps/web/src/components/sidebar/sidebarSelectors.ts index e31208e4b1..d96323f3b1 100644 --- a/apps/web/src/components/sidebar/sidebarSelectors.ts +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -8,10 +8,12 @@ import { scopeThreadRef, } from "@t3tools/client-runtime"; import { + sortProjectsForSidebar, sortThreadsForSidebar, type ThreadStatusLatestTurnSnapshot, type ThreadStatusSessionSnapshot, } from "../Sidebar.logic"; +import { THREAD_PREVIEW_LIMIT } from "./sidebarConstants"; import type { AppState, EnvironmentState } from "../../store"; import type { SidebarThreadSummary } from "../../types"; import type { LogicalProjectKey } from "../../logicalProject"; @@ -30,6 +32,7 @@ const EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS: SidebarProjectOrderingThreadSnaps const EMPTY_PROJECT_THREAD_KEYS: string[] = []; const EMPTY_PROJECT_THREAD_STATUS_INPUTS: ProjectThreadStatusInput[] = []; const EMPTY_SORTED_THREAD_KEYS_BY_LOGICAL_PROJECT = new Map(); +const EMPTY_SORTED_PROJECT_KEYS: LogicalProjectKey[] = []; function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); @@ -283,6 +286,54 @@ export function createSidebarProjectOrderingThreadSnapshotsSelector(input: { }; } +export function createSidebarSortedProjectKeysSelector(input: { + physicalToLogicalKey: ReadonlyMap; + projects: ReadonlyArray<{ + projectKey: LogicalProjectKey; + name: string; + createdAt?: string | undefined; + updatedAt?: string | undefined; + }>; + sortOrder: SidebarProjectSortOrder; +}): (state: AppState) => readonly LogicalProjectKey[] { + let previousResult: readonly LogicalProjectKey[] = EMPTY_SORTED_PROJECT_KEYS; + const orderingThreadSelector = createSidebarProjectOrderingThreadSnapshotsSelector({ + physicalToLogicalKey: input.physicalToLogicalKey, + sortOrder: input.sortOrder, + }); + + return (state) => { + const manualProjectKeys = input.projects.map((project) => project.projectKey); + if (input.sortOrder === "manual") { + if (stringArraysEqual(previousResult, manualProjectKeys)) { + return previousResult; + } + previousResult = + manualProjectKeys.length === 0 ? EMPTY_SORTED_PROJECT_KEYS : manualProjectKeys; + return previousResult; + } + + const sortableProjects = input.projects.map((project) => ({ + id: project.projectKey, + name: project.name, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })); + const sortedProjectKeys = sortProjectsForSidebar( + sortableProjects, + orderingThreadSelector(state), + input.sortOrder, + ).map((project) => project.id); + + if (stringArraysEqual(previousResult, sortedProjectKeys)) { + return previousResult; + } + + previousResult = sortedProjectKeys.length === 0 ? EMPTY_SORTED_PROJECT_KEYS : sortedProjectKeys; + return previousResult; + }; +} + export function createSidebarSortedThreadKeysByLogicalProjectSelector(input: { physicalToLogicalKey: ReadonlyMap; threadSortOrder: SidebarThreadSortOrder; @@ -353,6 +404,59 @@ export function createSidebarSortedThreadKeysByLogicalProjectSelector(input: { }; } +export function createSidebarVisibleThreadKeysSelector(input: { + expandedThreadListsByProject: ReadonlySet; + physicalToLogicalKey: ReadonlyMap; + projectExpandedStates: readonly boolean[]; + sortedProjectKeys: readonly LogicalProjectKey[]; + threadSortOrder: SidebarThreadSortOrder; + routeThreadKey: string | null; +}): (state: AppState) => readonly string[] { + let previousResult: readonly string[] = EMPTY_PROJECT_THREAD_KEYS; + const sortedThreadKeysByLogicalProjectSelector = + createSidebarSortedThreadKeysByLogicalProjectSelector({ + physicalToLogicalKey: input.physicalToLogicalKey, + threadSortOrder: input.threadSortOrder, + }); + + return (state) => { + const sortedThreadKeysByLogicalProject = sortedThreadKeysByLogicalProjectSelector(state); + const nextVisibleThreadKeys: string[] = []; + + input.sortedProjectKeys.forEach((projectKey, index) => { + const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; + const projectExpanded = input.projectExpandedStates[index] ?? true; + const activeThreadKey = input.routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return; + } + + const isThreadListExpanded = input.expandedThreadListsByProject.has(projectKey); + const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; + const visibleProjectThreadKeys = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreadKeys + : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); + nextVisibleThreadKeys.push( + ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreadKeys), + ); + }); + + if (stringArraysEqual(previousResult, nextVisibleThreadKeys)) { + return previousResult; + } + + previousResult = + nextVisibleThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : nextVisibleThreadKeys; + return previousResult; + }; +} + export function createSidebarThreadRowSnapshotSelectorByRef( ref: ScopedThreadRef | null | undefined, ): (state: AppState) => SidebarThreadRowSnapshot | undefined { From 0d335145eb044aefc0cd7f41eecb178b4e2f5e0d Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:59:11 +0100 Subject: [PATCH 06/21] Finalize sidebar key typing --- apps/web/src/components/Sidebar.tsx | 37 +++++--- .../components/sidebar/sidebarControllers.tsx | 89 +++++++++---------- .../components/sidebar/sidebarSelectors.ts | 54 ----------- .../components/sidebar/sidebarViewStore.ts | 23 ----- 4 files changed, 64 insertions(+), 139 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2a977c949c..1ba6053843 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -121,6 +121,7 @@ import { ThreadStatusPill, } from "./Sidebar.logic"; import { + createSidebarSortedProjectKeysSelector, createSidebarProjectRenderStateSelector, createSidebarProjectThreadStatusInputsSelector, createSidebarThreadMetaSnapshotSelectorByRef, @@ -131,7 +132,6 @@ import { import { THREAD_PREVIEW_LIMIT } from "./sidebar/sidebarConstants"; import { SidebarKeyboardController, - SidebarProjectOrderingController, SidebarSelectionController, } from "./sidebar/sidebarControllers"; import { @@ -140,7 +140,6 @@ import { resetSidebarViewState, useSidebarIsActiveThread, useSidebarProjectActiveRouteThreadKey, - useSidebarProjectKeys, useSidebarProjectThreadListExpanded, useSidebarThreadJumpLabel, } from "./sidebar/sidebarViewStore"; @@ -159,6 +158,7 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; +import type { LogicalProjectKey } from "../logicalProject"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -1966,7 +1966,8 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { }); interface SidebarProjectsContentProps { - sidebarProjectByKey: ReadonlyMap; + sortedProjectKeys: readonly LogicalProjectKey[]; + sidebarProjectByKey: ReadonlyMap; showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; desktopUpdateButtonAction: "download" | "install" | "none"; @@ -2007,6 +2008,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( props: SidebarProjectsContentProps, ) { const { + sortedProjectKeys, sidebarProjectByKey, showArm64IntelBuildWarning, arm64IntelBuildWarningDescription, @@ -2043,8 +2045,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( attachProjectListAutoAnimateRef, projectsLength, } = props; - const sortedProjectKeys = useSidebarProjectKeys(); - const handleProjectSortOrderChange = useCallback( (sortOrder: SidebarProjectSortOrder) => { updateSettings({ sidebarProjectSortOrder: sortOrder }); @@ -2330,8 +2330,8 @@ export default function Sidebar() { ); const previousSidebarProjectSnapshotByKeyRef = useRef< - ReadonlyMap - >(new Map()); + ReadonlyMap + >(new Map()); const sidebarProjects = useMemo(() => { const { projectSnapshotByKey, sidebarProjects: nextSidebarProjects } = buildSidebarProjectSnapshots({ @@ -2351,9 +2351,23 @@ export default function Sidebar() { ]); const sidebarProjectByKey = useMemo( - () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + () => + new Map( + sidebarProjects.map((project) => [project.projectKey, project] as const), + ), [sidebarProjects], ); + const sortedProjectKeys = useStore( + useMemo( + () => + createSidebarSortedProjectKeysSelector({ + physicalToLogicalKey, + projects: sidebarProjects, + sortOrder: sidebarProjectSortOrder, + }), + [physicalToLogicalKey, sidebarProjectSortOrder, sidebarProjects], + ), + ); const focusMostRecentThreadForProject = useCallback( (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { const physicalKey = scopedProjectKey( @@ -2686,15 +2700,11 @@ export default function Sidebar() { return ( <> - @@ -2703,6 +2713,7 @@ export default function Sidebar() { ) : ( <> (); @@ -98,27 +95,47 @@ function useSidebarKeyboardController(input: { ), ); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const visibleSidebarThreadKeys = useStore( + const sortedThreadKeysByLogicalProject = useStore( useMemo( () => - createSidebarVisibleThreadKeysSelector({ - expandedThreadListsByProject, + createSidebarSortedThreadKeysByLogicalProjectSelector({ physicalToLogicalKey, - projectExpandedStates, - routeThreadKey, - sortedProjectKeys, threadSortOrder, }), - [ - expandedThreadListsByProject, - physicalToLogicalKey, - projectExpandedStates, - routeThreadKey, - sortedProjectKeys, - threadSortOrder, - ], + [physicalToLogicalKey, threadSortOrder], ), ); + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjectKeys.flatMap((projectKey, index) => { + const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; + const projectExpanded = projectExpandedStates[index] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + + const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); + const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; + const previewThreadKeys = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreadKeys + : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); + return pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreadKeys; + }), + [ + expandedThreadListsByProject, + projectExpandedStates, + routeThreadKey, + sortedProjectKeys, + sortedThreadKeysByLogicalProject, + ], + ); const threadJumpCommandByKey = useMemo(() => { const mapping = new Map>>(); for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { @@ -360,40 +377,14 @@ export const SidebarSelectionController = memo(function SidebarSelectionControll return null; }); -export const SidebarProjectOrderingController = memo( - function SidebarProjectOrderingController(props: { - sidebarProjects: readonly SidebarProjectSnapshot[]; - physicalToLogicalKey: ReadonlyMap; - sidebarProjectSortOrder: SidebarProjectSortOrder; - }) { - const { sidebarProjects, physicalToLogicalKey, sidebarProjectSortOrder } = props; - const sortedProjectKeys = useStore( - useMemo( - () => - createSidebarSortedProjectKeysSelector({ - physicalToLogicalKey, - projects: sidebarProjects, - sortOrder: sidebarProjectSortOrder, - }), - [physicalToLogicalKey, sidebarProjectSortOrder, sidebarProjects], - ), - ); - - useEffect(() => { - setSidebarProjectOrdering(sortedProjectKeys); - }, [sortedProjectKeys]); - - return null; - }, -); - export const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { navigateToThread: (threadRef: ScopedThreadRef) => void; physicalToLogicalKey: ReadonlyMap; + sortedProjectKeys: readonly LogicalProjectKey[]; sidebarThreadSortOrder: SidebarThreadSortOrder; }) { - const { navigateToThread, physicalToLogicalKey, sidebarThreadSortOrder } = props; - const sortedProjectKeys = useSidebarProjectKeys(); + const { navigateToThread, physicalToLogicalKey, sortedProjectKeys, sidebarThreadSortOrder } = + props; const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); const routeThreadRef = useParams({ strict: false, diff --git a/apps/web/src/components/sidebar/sidebarSelectors.ts b/apps/web/src/components/sidebar/sidebarSelectors.ts index d96323f3b1..da25d42344 100644 --- a/apps/web/src/components/sidebar/sidebarSelectors.ts +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -13,7 +13,6 @@ import { type ThreadStatusLatestTurnSnapshot, type ThreadStatusSessionSnapshot, } from "../Sidebar.logic"; -import { THREAD_PREVIEW_LIMIT } from "./sidebarConstants"; import type { AppState, EnvironmentState } from "../../store"; import type { SidebarThreadSummary } from "../../types"; import type { LogicalProjectKey } from "../../logicalProject"; @@ -404,59 +403,6 @@ export function createSidebarSortedThreadKeysByLogicalProjectSelector(input: { }; } -export function createSidebarVisibleThreadKeysSelector(input: { - expandedThreadListsByProject: ReadonlySet; - physicalToLogicalKey: ReadonlyMap; - projectExpandedStates: readonly boolean[]; - sortedProjectKeys: readonly LogicalProjectKey[]; - threadSortOrder: SidebarThreadSortOrder; - routeThreadKey: string | null; -}): (state: AppState) => readonly string[] { - let previousResult: readonly string[] = EMPTY_PROJECT_THREAD_KEYS; - const sortedThreadKeysByLogicalProjectSelector = - createSidebarSortedThreadKeysByLogicalProjectSelector({ - physicalToLogicalKey: input.physicalToLogicalKey, - threadSortOrder: input.threadSortOrder, - }); - - return (state) => { - const sortedThreadKeysByLogicalProject = sortedThreadKeysByLogicalProjectSelector(state); - const nextVisibleThreadKeys: string[] = []; - - input.sortedProjectKeys.forEach((projectKey, index) => { - const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; - const projectExpanded = input.projectExpandedStates[index] ?? true; - const activeThreadKey = input.routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return; - } - - const isThreadListExpanded = input.expandedThreadListsByProject.has(projectKey); - const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; - const visibleProjectThreadKeys = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreadKeys - : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); - nextVisibleThreadKeys.push( - ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreadKeys), - ); - }); - - if (stringArraysEqual(previousResult, nextVisibleThreadKeys)) { - return previousResult; - } - - previousResult = - nextVisibleThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : nextVisibleThreadKeys; - return previousResult; - }; -} - export function createSidebarThreadRowSnapshotSelectorByRef( ref: ScopedThreadRef | null | undefined, ): (state: AppState) => SidebarThreadRowSnapshot | undefined { diff --git a/apps/web/src/components/sidebar/sidebarViewStore.ts b/apps/web/src/components/sidebar/sidebarViewStore.ts index 88beb0b297..9e3d96b64b 100644 --- a/apps/web/src/components/sidebar/sidebarViewStore.ts +++ b/apps/web/src/components/sidebar/sidebarViewStore.ts @@ -4,7 +4,6 @@ import { createStore } from "zustand/vanilla"; import type { LogicalProjectKey } from "../../logicalProject"; interface SidebarTransientState { - sortedProjectKeys: readonly LogicalProjectKey[]; activeRouteThreadKey: string | null; activeRouteProjectKey: LogicalProjectKey | null; threadJumpLabelByKey: ReadonlyMap; @@ -12,11 +11,9 @@ interface SidebarTransientState { } const EMPTY_THREAD_JUMP_LABELS = new Map(); -const EMPTY_SIDEBAR_PROJECT_KEYS: LogicalProjectKey[] = []; const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); const sidebarViewStore = createStore(() => ({ - sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, activeRouteThreadKey: null, activeRouteProjectKey: null, threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, @@ -25,7 +22,6 @@ const sidebarViewStore = createStore(() => ({ export function resetSidebarViewState(): void { sidebarViewStore.setState({ - sortedProjectKeys: EMPTY_SIDEBAR_PROJECT_KEYS, activeRouteThreadKey: null, activeRouteProjectKey: null, threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, @@ -33,14 +29,6 @@ export function resetSidebarViewState(): void { }); } -export function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - -export function useSidebarProjectKeys(): readonly LogicalProjectKey[] { - return useZustandStore(sidebarViewStore, (state) => state.sortedProjectKeys); -} - export function useSidebarProjectThreadListExpanded(projectKey: LogicalProjectKey): boolean { return useZustandStore( sidebarViewStore, @@ -116,17 +104,6 @@ export function useSidebarProjectActiveRouteThreadKey( ); } -export function setSidebarProjectOrdering(sortedProjectKeys: readonly LogicalProjectKey[]): void { - const currentState = sidebarViewStore.getState(); - if (stringArraysEqual(currentState.sortedProjectKeys, sortedProjectKeys)) { - return; - } - - sidebarViewStore.setState({ - sortedProjectKeys, - }); -} - export function setSidebarKeyboardState(input: { activeRouteProjectKey: LogicalProjectKey | null; activeRouteThreadKey: string | null; From 0baa91a3638176f7604e62ab98c43c7ef8b5df04 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:34:12 +0100 Subject: [PATCH 07/21] start --- apps/web/src/components/ChatView.tsx | 160 ++---------- .../chat/MessagesTimelineContainer.tsx | 243 ++++++++++++++++++ 2 files changed, 268 insertions(+), 135 deletions(-) create mode 100644 apps/web/src/components/chat/MessagesTimelineContainer.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5a2875cc..270a33acb2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -42,12 +42,9 @@ import { parseStandaloneComposerSlashCommand, } from "../composer-logic"; import { - deriveCompletionDividerBeforeEntryId, derivePendingApprovals, derivePendingUserInputs, derivePhase, - deriveTimelineEntries, - deriveActiveWorkStartedAt, deriveActivePlanState, findSidebarProposedPlan, findLatestProposedPlan, @@ -55,7 +52,6 @@ import { hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, - formatElapsed, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { @@ -85,10 +81,8 @@ import { type ChatMessage, type SessionPhase, type Thread, - type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; -import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -131,7 +125,7 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; -import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { MessagesTimelineContainer } from "./chat/MessagesTimelineContainer"; import { ChatHeader } from "./chat/ChatHeader"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; @@ -673,14 +667,12 @@ export default function ChatView(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. const planSidebarOpenOnNextThreadRef = useRef(false); - const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -1144,12 +1136,7 @@ export default function ChatView(props: ChatViewProps) { threadError: activeThread?.error, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; - const nowIso = new Date(nowTick).toISOString(); - const activeWorkStartedAt = deriveActiveWorkStartedAt( - activeLatestTurn, - activeThread?.session ?? null, - localDispatchStartedAt, - ); + const activeTurnInProgress = isWorking || !latestTurnSettled; useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -1344,73 +1331,6 @@ export default function ChatView(props: ChatViewProps) { } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); - const timelineEntries = useMemo( - () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], - ); - const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = - useTurnDiffSummaries(activeThread); - const turnDiffSummaryByAssistantMessageId = useMemo(() => { - const byMessageId = new Map(); - for (const summary of turnDiffSummaries) { - if (!summary.assistantMessageId) continue; - byMessageId.set(summary.assistantMessageId, summary); - } - return byMessageId; - }, [turnDiffSummaries]); - const revertTurnCountByUserMessageId = useMemo(() => { - const byUserMessageId = new Map(); - for (let index = 0; index < timelineEntries.length; index += 1) { - const entry = timelineEntries[index]; - if (!entry || entry.kind !== "message" || entry.message.role !== "user") { - continue; - } - - for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { - const nextEntry = timelineEntries[nextIndex]; - if (!nextEntry || nextEntry.kind !== "message") { - continue; - } - if (nextEntry.message.role === "user") { - break; - } - const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); - if (!summary) { - continue; - } - const turnCount = - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (typeof turnCount !== "number") { - break; - } - byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); - break; - } - } - - return byUserMessageId; - }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); - - const completionSummary = useMemo(() => { - if (!latestTurnSettled) return null; - if (!activeLatestTurn?.startedAt) return null; - if (!activeLatestTurn.completedAt) return null; - if (!latestTurnHasToolActivity) return null; - - const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); - return elapsed ? `Worked for ${elapsed}` : null; - }, [ - activeLatestTurn?.completedAt, - activeLatestTurn?.startedAt, - latestTurnHasToolActivity, - latestTurnSettled, - ]); - const completionDividerBeforeEntryId = useMemo(() => { - if (!latestTurnSettled) return null; - if (!completionSummary) return null; - return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); - }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); const gitCwd = activeProject ? projectScriptCwd({ project: { cwd: activeProject.cwd }, @@ -2125,13 +2045,6 @@ export default function ChatView(props: ChatViewProps) { scheduleStickToBottom(); }, [messageCount, scheduleStickToBottom]); useEffect(() => { - if (phase !== "running") return; - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }, [phase, scheduleStickToBottom, timelineEntries]); - - useEffect(() => { - setExpandedWorkGroups({}); setPullRequestDialogState(null); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; @@ -2290,16 +2203,6 @@ export default function ChatView(props: ChatViewProps) { terminalState.terminalOpen, ]); - useEffect(() => { - if (phase !== "running") return; - const timer = window.setInterval(() => { - setNowTick(Date.now()); - }, 1000); - return () => { - window.clearInterval(timer); - }; - }, [phase]); - useEffect(() => { if (!activeThreadKey) return; const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; @@ -3241,12 +3144,6 @@ export default function ChatView(props: ChatViewProps) { ], ); - const onToggleWorkGroup = useCallback((groupId: string) => { - setExpandedWorkGroups((existing) => ({ - ...existing, - [groupId]: !existing[groupId], - })); - }, []); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -3272,17 +3169,6 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], ); - const onRevertUserMessage = useCallback( - (messageId: MessageId) => { - const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); - if (typeof targetTurnCount !== "number") { - return; - } - void onRevertToTurnCount(targetTurnCount); - }, - [onRevertToTurnCount, revertTurnCountByUserMessageId], - ); - // Empty state: no active thread if (!activeThread) { return ; @@ -3353,32 +3239,36 @@ export default function ChatView(props: ChatViewProps) { onTouchEnd={onMessagesTouchEnd} onTouchCancel={onMessagesTouchEnd} > - 0} - isWorking={isWorking} - activeTurnInProgress={isWorking || !latestTurnSettled} + { + void onRevertToTurnCount(turnCount); + }} + onSetChangedFilesExpanded={handleSetChangedFilesExpanded} + phase={phase} + proposedPlans={activeThread.proposedPlans} resolvedTheme={resolvedTheme} + scheduleStickToBottom={scheduleStickToBottom} + scrollContainer={messagesScrollElement} + shouldAutoScrollRef={shouldAutoScrollRef} + timelineMessages={timelineMessages} timestampFormat={timestampFormat} + turnDiffSummaries={activeThread.turnDiffSummaries} + workLogEntries={workLogEntries} workspaceRoot={activeWorkspaceRoot} /> diff --git a/apps/web/src/components/chat/MessagesTimelineContainer.tsx b/apps/web/src/components/chat/MessagesTimelineContainer.tsx new file mode 100644 index 0000000000..ddc1600a22 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimelineContainer.tsx @@ -0,0 +1,243 @@ +import { type EnvironmentId, type MessageId, type TurnId, type ThreadId } from "@t3tools/contracts"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + deriveActiveWorkStartedAt, + deriveCompletionDividerBeforeEntryId, + deriveTimelineEntries, + deriveWorkLogEntries, + formatElapsed, + inferCheckpointTurnCountByTurnId, +} from "../../session-logic"; +import { + type SessionPhase, + type ChatMessage, + type Thread, + type TurnDiffSummary, +} from "../../types"; +import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { MessagesTimeline } from "./MessagesTimeline"; +import { type ExpandedImagePreview } from "./ExpandedImagePreview"; + +type WorkLogEntries = ReturnType; + +interface MessagesTimelineContainerProps { + activeLatestTurn: Thread["latestTurn"] | null; + activeTurnId: TurnId | null; + activeTurnInProgress: boolean; + activeThreadEnvironmentId: EnvironmentId; + activeThreadId: ThreadId; + activeThreadSession: Thread["session"] | null; + changedFilesExpandedByTurnId: Record; + isRevertingCheckpoint: boolean; + isWorking: boolean; + latestTurnHasToolActivity: boolean; + latestTurnSettled: boolean; + localDispatchStartedAt: string | null; + markdownCwd: string | undefined; + onImageExpand: (preview: ExpandedImagePreview) => void; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onRevertToTurnCount: (turnCount: number) => void; + onSetChangedFilesExpanded: (turnId: TurnId, expanded: boolean) => void; + phase: SessionPhase; + proposedPlans: Thread["proposedPlans"]; + resolvedTheme: "light" | "dark"; + scheduleStickToBottom: () => void; + scrollContainer: HTMLDivElement | null; + shouldAutoScrollRef: React.MutableRefObject; + timelineMessages: ChatMessage[]; + timestampFormat: TimestampFormat; + turnDiffSummaries: Thread["turnDiffSummaries"]; + workLogEntries: WorkLogEntries; + workspaceRoot: string | undefined; +} + +export const MessagesTimelineContainer = memo(function MessagesTimelineContainer( + props: MessagesTimelineContainerProps, +) { + const { + activeLatestTurn, + activeTurnId, + activeTurnInProgress, + activeThreadEnvironmentId, + activeThreadId, + activeThreadSession, + changedFilesExpandedByTurnId, + isRevertingCheckpoint, + isWorking, + latestTurnHasToolActivity, + latestTurnSettled, + localDispatchStartedAt, + markdownCwd, + onImageExpand, + onOpenTurnDiff, + onRevertToTurnCount, + onSetChangedFilesExpanded, + phase, + proposedPlans, + resolvedTheme, + scheduleStickToBottom, + scrollContainer, + shouldAutoScrollRef, + timelineMessages, + timestampFormat, + turnDiffSummaries, + workLogEntries, + workspaceRoot, + } = props; + const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); + const [nowTick, setNowTick] = useState(() => Date.now()); + const nowIso = useMemo(() => new Date(nowTick).toISOString(), [nowTick]); + const activeWorkStartedAt = useMemo( + () => deriveActiveWorkStartedAt(activeLatestTurn, activeThreadSession, localDispatchStartedAt), + [activeLatestTurn, activeThreadSession, localDispatchStartedAt], + ); + const timelineEntries = useMemo( + () => deriveTimelineEntries(timelineMessages, proposedPlans, workLogEntries), + [proposedPlans, timelineMessages, workLogEntries], + ); + const turnDiffSummaryByAssistantMessageId = useMemo(() => { + const byMessageId = new Map(); + for (const summary of turnDiffSummaries) { + if (!summary.assistantMessageId) { + continue; + } + byMessageId.set(summary.assistantMessageId, summary); + } + return byMessageId; + }, [turnDiffSummaries]); + const inferredCheckpointTurnCountByTurnId = useMemo( + () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), + [turnDiffSummaries], + ); + const revertTurnCountByUserMessageId = useMemo(() => { + const byUserMessageId = new Map(); + for (let index = 0; index < timelineEntries.length; index += 1) { + const entry = timelineEntries[index]; + if (!entry || entry.kind !== "message" || entry.message.role !== "user") { + continue; + } + + for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { + const nextEntry = timelineEntries[nextIndex]; + if (!nextEntry || nextEntry.kind !== "message") { + continue; + } + if (nextEntry.message.role === "user") { + break; + } + const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); + if (!summary) { + continue; + } + const turnCount = + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (typeof turnCount !== "number") { + break; + } + byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); + break; + } + } + + return byUserMessageId; + }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + const completionSummary = useMemo(() => { + if (!latestTurnSettled) return null; + if (!activeLatestTurn?.startedAt) return null; + if (!activeLatestTurn.completedAt) return null; + if (!latestTurnHasToolActivity) return null; + + const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); + return elapsed ? `Worked for ${elapsed}` : null; + }, [ + activeLatestTurn?.completedAt, + activeLatestTurn?.startedAt, + latestTurnHasToolActivity, + latestTurnSettled, + ]); + const completionDividerBeforeEntryId = useMemo(() => { + if (!latestTurnSettled) return null; + if (!completionSummary) return null; + return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); + }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); + const messageCount = timelineMessages.length; + const onToggleWorkGroup = useCallback((groupId: string) => { + setExpandedWorkGroups((existing) => ({ + ...existing, + [groupId]: !existing[groupId], + })); + }, []); + const onRevertUserMessage = useCallback( + (messageId: MessageId) => { + const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); + if (typeof targetTurnCount !== "number") { + return; + } + onRevertToTurnCount(targetTurnCount); + }, + [onRevertToTurnCount, revertTurnCountByUserMessageId], + ); + const activeThreadIdRef = useRef(activeThreadId); + + useEffect(() => { + if (activeThreadIdRef.current === activeThreadId) { + return; + } + activeThreadIdRef.current = activeThreadId; + setExpandedWorkGroups({}); + }, [activeThreadId]); + + useEffect(() => { + if (phase !== "running") { + return; + } + const timer = window.setInterval(() => { + setNowTick(Date.now()); + }, 1000); + return () => { + window.clearInterval(timer); + }; + }, [phase]); + + useEffect(() => { + if (!shouldAutoScrollRef.current) return; + scheduleStickToBottom(); + }, [messageCount, scheduleStickToBottom, shouldAutoScrollRef]); + + useEffect(() => { + if (phase !== "running") return; + if (!shouldAutoScrollRef.current) return; + scheduleStickToBottom(); + }, [phase, scheduleStickToBottom, shouldAutoScrollRef, timelineEntries]); + + return ( + 0} + isWorking={isWorking} + activeTurnInProgress={activeTurnInProgress} + activeTurnId={activeTurnId} + activeTurnStartedAt={activeWorkStartedAt} + scrollContainer={scrollContainer} + timelineEntries={timelineEntries} + completionDividerBeforeEntryId={completionDividerBeforeEntryId} + completionSummary={completionSummary} + turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} + nowIso={nowIso} + activeThreadEnvironmentId={activeThreadEnvironmentId} + expandedWorkGroups={expandedWorkGroups} + onToggleWorkGroup={onToggleWorkGroup} + changedFilesExpandedByTurnId={changedFilesExpandedByTurnId} + onSetChangedFilesExpanded={onSetChangedFilesExpanded} + onOpenTurnDiff={onOpenTurnDiff} + revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} + onRevertUserMessage={onRevertUserMessage} + isRevertingCheckpoint={isRevertingCheckpoint} + onImageExpand={onImageExpand} + markdownCwd={markdownCwd} + resolvedTheme={resolvedTheme} + timestampFormat={timestampFormat} + workspaceRoot={workspaceRoot} + /> + ); +}); From 1d6a6a6c871f80dedd5eb233cd5c3f581a3e9fdf Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:44:20 +0100 Subject: [PATCH 08/21] something --- apps/web/index.html | 4 + apps/web/src/components/BranchToolbar.tsx | 10 +- apps/web/src/components/ChatView.logic.ts | 20 +- apps/web/src/components/ChatView.tsx | 1201 +++++++++++------ apps/web/src/components/chat/ChatComposer.tsx | 183 ++- .../src/components/chat/MessagesTimeline.tsx | 825 +++++++---- .../chat/MessagesTimelineContainer.tsx | 266 +++- apps/web/src/storeSelectors.ts | 523 ++++++- 8 files changed, 2254 insertions(+), 778 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 9f0329b602..d64ae31a32 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -8,6 +8,9 @@ + + + -