diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ebfc260ca43..8394a57083f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -80,6 +80,7 @@ import { useStore, } from "../store"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useUiStateStore } from "../uiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { ADDON_ICON_CLASS, @@ -406,6 +407,14 @@ function OpenCommandPaletteDialog() { useHandleNewThread(); const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + const threadOrderByProject = useUiStateStore((state) => state.threadOrderByProject); + // Flattened manual thread order so "open project" navigates to the thread + // that is first in the sidebar under manual sort; the per-project filter in + // getLatestThreadForProject means only the target project's keys match. + const manualThreadOrder = useMemo( + () => Object.values(threadOrderByProject).flat(), + [threadOrderByProject], + ); const keybindings = useServerKeybindings(); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; @@ -601,6 +610,7 @@ function OpenCommandPaletteDialog() { threads.filter((thread) => thread.environmentId === project.environmentId), project.id, settings.sidebarThreadSortOrder, + manualThreadOrder, ); if (latestThread) { await navigate({ @@ -618,6 +628,7 @@ function OpenCommandPaletteDialog() { }, [ handleNewThread, + manualThreadOrder, navigate, settings.defaultThreadEnvMode, settings.sidebarThreadSortOrder, @@ -1099,6 +1110,7 @@ function OpenCommandPaletteDialog() { threads.filter((thread) => thread.environmentId === existing.environmentId), existing.id, settings.sidebarThreadSortOrder, + manualThreadOrder, ); if (latestThread) { await navigate({ @@ -1150,6 +1162,7 @@ function OpenCommandPaletteDialog() { browseEnvironmentPlatform, currentProjectCwdForBrowse, handleNewThread, + manualThreadOrder, navigate, projects, setOpen, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bdbbf6f8491..c806a2ba06e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { ProviderDriverKind } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { createThreadJumpHintVisibilityController, @@ -811,6 +812,26 @@ describe("getFallbackThreadIdAfterDelete", () => { expect(fallbackThreadId).toBe(ThreadId.make("thread-next")); }); + + it("falls back to the first thread in manual order, not raw store order", () => { + const threads = [ + makeThread({ id: ThreadId.make("thread-a"), projectId: ProjectId.make("project-1") }), + makeThread({ id: ThreadId.make("thread-active"), projectId: ProjectId.make("project-1") }), + makeThread({ id: ThreadId.make("thread-c"), projectId: ProjectId.make("project-1") }), + ]; + const keyFor = (id: string) => + scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make(id))); + + const fallbackThreadId = getFallbackThreadIdAfterDelete({ + threads, + deletedThreadId: ThreadId.make("thread-active"), + sortOrder: "manual", + // Manual order puts thread-c first, ahead of thread-a in store order. + manualThreadOrder: [keyFor("thread-c"), keyFor("thread-active"), keyFor("thread-a")], + }); + + expect(fallbackThreadId).toBe(ThreadId.make("thread-c")); + }); }); describe("sortProjectsForSidebar", () => { it("sorts projects by the most recent user message across their threads", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index b9dd27dfb03..636c2f55e28 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -2,11 +2,17 @@ import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, + orderItemsByPreferredIds, sortThreads, toSortableTimestamp, type ThreadSortInput, } from "../lib/threadSort"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; import type { SidebarThreadSummary, Thread } from "../types"; + +// Re-exported from lib/threadSort so existing importers keep using this path; +// it lives in the sort lib to stay reusable without an import cycle. +export { orderItemsByPreferredIds }; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; @@ -213,34 +219,6 @@ export function resolveSidebarNewThreadSeedContext(input: { }; } -export function orderItemsByPreferredIds(input: { - items: readonly TItem[]; - preferredIds: readonly TId[]; - getId: (item: TItem) => TId; -}): TItem[] { - const { getId, items, preferredIds } = input; - if (preferredIds.length === 0) { - return [...items]; - } - - const itemsById = new Map(items.map((item) => [getId(item), item] as const)); - const preferredIdSet = new Set(preferredIds); - const emittedPreferredIds = new Set(); - const ordered = preferredIds.flatMap((id) => { - if (emittedPreferredIds.has(id)) { - return []; - } - const item = itemsById.get(id); - if (!item) { - return []; - } - emittedPreferredIds.add(id); - return [item]; - }); - const remaining = items.filter((item) => !preferredIdSet.has(getId(item))); - return [...ordered, ...remaining]; -} - export function getVisibleSidebarThreadIds( renderedProjects: readonly { shouldShowThreadPanel?: boolean; @@ -460,30 +438,41 @@ export function getVisibleThreadsForProject>(input: } export function getFallbackThreadIdAfterDelete< - T extends Pick & ThreadSortInput, + T extends Pick & + ThreadSortInput, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; sortOrder: SidebarThreadSortOrder; deletedThreadIds?: ReadonlySet; + // Manual order (scoped thread keys) so the fallback matches the thread that + // is actually first in the sidebar under manual sort, not raw store order. + manualThreadOrder?: readonly string[]; }): T["id"] | null { - const { deletedThreadId, deletedThreadIds, sortOrder, threads } = input; + const { deletedThreadId, deletedThreadIds, manualThreadOrder, sortOrder, threads } = input; const deletedThread = threads.find((thread) => thread.id === deletedThreadId); if (!deletedThread) { return null; } - return ( - sortThreads( - threads.filter( - (thread) => - thread.projectId === deletedThread.projectId && - thread.id !== deletedThreadId && - !deletedThreadIds?.has(thread.id), - ), - sortOrder, - )[0]?.id ?? null + const candidates = sortThreads( + threads.filter( + (thread) => + thread.projectId === deletedThread.projectId && + thread.id !== deletedThreadId && + !deletedThreadIds?.has(thread.id), + ), + sortOrder, ); + const ordered = + sortOrder === "manual" && manualThreadOrder && manualThreadOrder.length > 0 + ? orderItemsByPreferredIds({ + items: candidates, + preferredIds: manualThreadOrder, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }) + : candidates; + return ordered[0]?.id ?? null; } export function getProjectSortTimestamp( project: SidebarProject, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dc5acaaadc7..00b5467490a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -206,7 +206,9 @@ const SIDEBAR_SORT_LABELS: Record = { const SIDEBAR_THREAD_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", + manual: "Manual", }; +const EMPTY_THREAD_ORDER: readonly string[] = []; const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", @@ -279,10 +281,16 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } +type SortableThreadHandleProps = Pick< + ReturnType, + "attributes" | "listeners" | "setActivatorNodeRef" | "setNodeRef" | "isDragging" | "isOver" +> & { style: React.CSSProperties }; + interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; orderedProjectThreadKeys: readonly string[]; + dragHandleProps: SortableThreadHandleProps | null; isActive: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; @@ -340,6 +348,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP attemptArchiveThread, openPrLink, thread, + dragHandleProps, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); @@ -495,6 +504,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const handleRenameInputClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); }, []); + // Keep drag listeners on the row button from hijacking pointer-down while the + // inline rename input is focused (manual sort mode attaches them to the row). + const handleRenameInputPointerDown = useCallback( + (event: React.PointerEvent) => { + event.stopPropagation(); + }, + [], + ); const handleConfirmArchiveRef = useCallback( (element: HTMLButtonElement | null) => { if (element) { @@ -543,12 +560,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP return (
{prStatus && ( @@ -589,6 +615,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onKeyDown={handleRenameInputKeyDown} onBlur={handleRenameInputBlur} onClick={handleRenameInputClick} + onPointerDown={handleRenameInputPointerDown} /> ) : ( @@ -734,9 +761,47 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); }); +function SortableThreadItem({ + threadKey, + children, +}: { + threadKey: string; + children: (handleProps: SortableThreadHandleProps) => React.ReactNode; +}) { + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ id: threadKey }); + const style: React.CSSProperties = { + transform: CSS.Translate.toString(transform), + transition, + }; + return children({ + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + isDragging, + isOver, + style, + }); +} + interface SidebarProjectThreadListProps { projectKey: string; projectExpanded: boolean; + isManualThreadSorting: boolean; + threadDnDSensors: ReturnType; + threadCollisionDetection: CollisionDetection; + handleThreadDragStart: (event: DragStartEvent) => void; + handleThreadDragEnd: (event: DragEndEvent) => void; + handleThreadDragCancel: (event: DragCancelEvent) => void; hasOverflowingThreads: boolean; hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; @@ -787,6 +852,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( const { projectKey, projectExpanded, + isManualThreadSorting, + threadDnDSensors, + threadCollisionDetection, + handleThreadDragStart, + handleThreadDragEnd, + handleThreadDragCancel, hasOverflowingThreads, hiddenThreadStatus, orderedProjectThreadKeys, @@ -838,37 +909,92 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( ) : null} {shouldShowThreadPanel && - renderedThreads.map((thread) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - return ( - - ); - })} + (isManualThreadSorting ? ( + + + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + )} + strategy={verticalListSortingStrategy} + > + {renderedThreads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return ( + + {(dragHandleProps) => ( + + )} + + ); + })} + + + ) : ( + renderedThreads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return ( + + ); + }) + ))} {projectExpanded && hasOverflowingThreads && !isThreadListExpanded && ( @@ -948,6 +1074,25 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadSortOrder = useSettings( (settings) => settings.sidebarThreadSortOrder, ); + const isManualThreadSorting = threadSortOrder === "manual"; + const reorderThreads = useUiStateStore((state) => state.reorderThreads); + const threadOrder = useUiStateStore( + useShallow((state) => state.threadOrderByProject[project.projectKey] ?? EMPTY_THREAD_ORDER), + ); + const threadDragInProgressRef = useRef(false); + const suppressThreadClickAfterDragRef = useRef(false); + const threadDnDSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + ); + const threadCollisionDetection = useCallback((args) => { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + return closestCorners(args); + }, []); const appSettingsConfirmThreadDelete = useSettings( (settings) => settings.confirmThreadDelete, ); @@ -1131,10 +1276,18 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const visibleProjectThreads = sortThreads( + const sortedProjectThreads = sortThreads( projectThreads.filter((thread) => thread.archivedAt === null), threadSortOrder, ); + const visibleProjectThreads = + threadSortOrder === "manual" + ? orderItemsByPreferredIds({ + items: sortedProjectThreads, + preferredIds: threadOrder, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }) + : sortedProjectThreads; const projectStatus = resolveProjectStatusIndicator( visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)), ); @@ -1145,7 +1298,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectStatus, visibleProjectThreads, }; - }, [projectThreads, threadLastVisitedAts, threadSortOrder]); + }, [projectThreads, threadLastVisitedAts, threadSortOrder, threadOrder]); const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; @@ -1184,7 +1337,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount; + // Manual sort makes the whole list reorderable, so never hide rows behind + // "Show more" — render every thread and suppress the overflow controls. + const hasOverflowingThreads = + !isManualThreadSorting && visibleProjectThreads.length > sidebarThreadPreviewCount; const previewThreads = isThreadListExpanded || !hasOverflowingThreads ? visibleProjectThreads @@ -1213,6 +1369,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null, }; }, [ + isManualThreadSorting, isThreadListExpanded, pinnedCollapsedThread, projectExpanded, @@ -1570,6 +1727,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec threadRef: ScopedThreadRef, orderedProjectThreadKeys: readonly string[], ) => { + // A drag in flight (or one that just finished) must not fall through to a + // navigate / multi-select; mirror the project drag suppression pattern. + if (threadDragInProgressRef.current) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (suppressThreadClickAfterDragRef.current) { + suppressThreadClickAfterDragRef.current = false; + event.preventDefault(); + event.stopPropagation(); + return; + } const isMac = isMacPlatform(navigator.platform); const isModClick = isMac ? event.metaKey : event.ctrlKey; const isShiftClick = event.shiftKey; @@ -1611,6 +1781,41 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ], ); + const handleThreadDragStart = useCallback( + (_event: DragStartEvent) => { + if (!isManualThreadSorting) { + return; + } + threadDragInProgressRef.current = true; + suppressThreadClickAfterDragRef.current = true; + }, + [isManualThreadSorting], + ); + + const handleThreadDragEnd = useCallback( + (event: DragEndEvent) => { + threadDragInProgressRef.current = false; + if (!isManualThreadSorting) { + return; + } + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + reorderThreads( + project.projectKey, + orderedProjectThreadKeys, + [String(active.id)], + String(over.id), + ); + }, + [isManualThreadSorting, orderedProjectThreadKeys, project.projectKey, reorderThreads], + ); + + const handleThreadDragCancel = useCallback((_event: DragCancelEvent) => { + threadDragInProgressRef.current = false; + }, []); + const handleMultiSelectContextMenu = useCallback( async (position: { x: number; y: number }) => { const api = readLocalApi(); @@ -2099,6 +2304,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); + const threadOrderByProject = useUiStateStore((store) => store.threadOrderByProject); const reorderProjects = useUiStateStore((store) => store.reorderProjects); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); @@ -3094,15 +3306,25 @@ export default function Sidebar() { visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + const isManualThreadSorting = sidebarThreadSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { - const projectThreads = sortThreads( + const sortedProjectThreads = sortThreads( (threadsByProjectKey.get(project.projectKey) ?? []).filter( (thread) => thread.archivedAt === null, ), sidebarThreadSortOrder, ); + // Keep this in lockstep with SidebarProjectItem so thread-jump indices + // and prewarm targets match the on-screen order under manual sort. + const projectThreads = isManualThreadSorting + ? orderItemsByPreferredIds({ + items: sortedProjectThreads, + preferredIds: threadOrderByProject[project.projectKey] ?? EMPTY_THREAD_ORDER, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }) + : sortedProjectThreads; const projectExpanded = projectExpandedById[project.projectKey] ?? true; const activeThreadKey = routeThreadKey ?? undefined; const pinnedCollapsedThread = @@ -3118,7 +3340,8 @@ export default function Sidebar() { return []; } const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; + const hasOverflowingThreads = + !isManualThreadSorting && projectThreads.length > sidebarThreadPreviewCount; const previewThreads = isThreadListExpanded || !hasOverflowingThreads ? projectThreads @@ -3129,6 +3352,7 @@ export default function Sidebar() { ); }), [ + isManualThreadSorting, sidebarThreadSortOrder, sidebarThreadPreviewCount, expandedThreadListsByProject, @@ -3136,6 +3360,7 @@ export default function Sidebar() { routeThreadKey, sortedProjects, threadsByProjectKey, + threadOrderByProject, ], ); const threadJumpCommandByKey = useMemo(() => { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 7325a96913d..a3502c1b2a9 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -18,6 +18,7 @@ import { useStore, } from "../store"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useUiStateStore } from "../uiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; @@ -181,6 +182,9 @@ export function useThreadActions() { deletedThreadId: threadRef.threadId, deletedThreadIds, sortOrder: sidebarThreadSortOrder, + // Flatten every project's manual order; the per-project filter inside + // the helper means only the deleted thread's siblings actually match. + manualThreadOrder: Object.values(useUiStateStore.getState().threadOrderByProject).flat(), }); await api.orchestration.dispatchCommand({ type: "thread.delete", diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index ad4126e4b30..efc46523885 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -6,6 +6,7 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import type { Thread } from "../types"; import { getLatestThreadForProject, sortThreads } from "./threadSort"; @@ -238,4 +239,21 @@ describe("sortThreads", () => { expect(latestThread?.id).toBe(ThreadId.make("thread-3")); }); + + it("returns the first thread in manual order when sort is manual", () => { + const keyFor = (id: string) => + scopedThreadKey(scopeThreadRef(LOCAL_ENVIRONMENT_ID, ThreadId.make(id))); + const latestThread = getLatestThreadForProject( + [ + makeThread({ id: ThreadId.make("thread-1"), archivedAt: null }), + makeThread({ id: ThreadId.make("thread-2"), archivedAt: null }), + makeThread({ id: ThreadId.make("thread-3"), archivedAt: null }), + ], + PROJECT_ID, + "manual", + [keyFor("thread-2"), keyFor("thread-1"), keyFor("thread-3")], + ); + + expect(latestThread?.id).toBe(ThreadId.make("thread-2")); + }); }); diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index de8b22e93c5..075641dafdd 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -1,7 +1,41 @@ import type { ProjectId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; import type { Thread } from "../types"; +/** + * Reorders `items` to match `preferredIds` first, then appends any items not + * listed in `preferredIds` in their original order. Duplicates and unknown ids + * are ignored. Generic so it can order projects, threads, or anything keyed. + */ +export function orderItemsByPreferredIds(input: { + items: readonly TItem[]; + preferredIds: readonly TId[]; + getId: (item: TItem) => TId; +}): TItem[] { + const { getId, items, preferredIds } = input; + if (preferredIds.length === 0) { + return [...items]; + } + + const itemsById = new Map(items.map((item) => [getId(item), item] as const)); + const preferredIdSet = new Set(preferredIds); + const emittedPreferredIds = new Set(); + const ordered = preferredIds.flatMap((id) => { + if (emittedPreferredIds.has(id)) { + return []; + } + const item = itemsById.get(id); + if (!item) { + return []; + } + emittedPreferredIds.add(id); + return [item]; + }); + const remaining = items.filter((item) => !preferredIdSet.has(getId(item))); + return [...ordered, ...remaining]; +} + export type ThreadSortInput = Pick & { latestUserMessageAt?: string | null; messages?: Pick[]; @@ -64,6 +98,11 @@ export function sortThreads & ThreadSortInput>( threads: readonly T[], sortOrder: SidebarThreadSortOrder, ): T[] { + // Manual sort preserves the incoming order; the caller layers the + // user-defined order on top via `orderItemsByPreferredIds`. + if (sortOrder === "manual") { + return [...threads]; + } return threads.toSorted((left, right) => { const rightTimestamp = getThreadSortTimestamp(right, sortOrder); const leftTimestamp = getThreadSortTimestamp(left, sortOrder); @@ -75,12 +114,26 @@ export function sortThreads & ThreadSortInput>( } export function getLatestThreadForProject< - T extends Pick & ThreadSortInput, ->(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { - return ( - sortThreads( - threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), - sortOrder, - )[0] ?? null + T extends Pick & ThreadSortInput, +>( + threads: readonly T[], + projectId: ProjectId, + sortOrder: SidebarThreadSortOrder, + // Manual order (scoped thread keys) so "latest" matches the sidebar's + // user-defined order rather than raw store order under manual sort. + manualThreadOrder: readonly string[] = [], +): T | null { + const sorted = sortThreads( + threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), + sortOrder, ); + const ordered = + sortOrder === "manual" && manualThreadOrder.length > 0 + ? orderItemsByPreferredIds({ + items: sorted, + preferredIds: manualThreadOrder, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }) + : sorted; + return ordered[0] ?? null; } diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index c6f445b0c32..c254fcf4cc3 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -10,6 +10,7 @@ import { type PersistedUiState, persistState, reorderProjects, + reorderThreads, setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, @@ -24,6 +25,7 @@ function makeUiState(overrides: Partial = {}): UiState { projectOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, + threadOrderByProject: {}, defaultAdvertisedEndpointKey: null, ...overrides, }; @@ -366,6 +368,98 @@ describe("uiStateStore pure functions", () => { }); }); + it("syncThreads prunes stale thread keys and empty projects from manual order", () => { + const thread1 = ThreadId.make("thread-1"); + const thread2 = ThreadId.make("thread-2"); + const thread3 = ThreadId.make("thread-3"); + const initialState = makeUiState({ + threadOrderByProject: { + "env-local:proj-a": [thread1, thread2], + "env-local:proj-b": [thread3], + }, + }); + + const next = syncThreads(initialState, [{ key: thread1 }]); + + expect(next.threadOrderByProject).toEqual({ + "env-local:proj-a": [thread1], + }); + }); + + it("syncThreads leaves manual order untouched when every thread is retained", () => { + const thread1 = ThreadId.make("thread-1"); + const thread2 = ThreadId.make("thread-2"); + const initialState = makeUiState({ + threadOrderByProject: { + "env-local:proj-a": [thread1, thread2], + }, + }); + + const next = syncThreads(initialState, [{ key: thread1 }, { key: thread2 }]); + + expect(next.threadOrderByProject).toBe(initialState.threadOrderByProject); + }); + + it("reorderThreads moves a thread down past its target within a project", () => { + const initialState = makeUiState(); + + const next = reorderThreads( + initialState, + "env-local:proj-a", + ["t-1", "t-2", "t-3"], + ["t-1"], + "t-3", + ); + + expect(next.threadOrderByProject["env-local:proj-a"]).toEqual(["t-2", "t-3", "t-1"]); + }); + + it("reorderThreads moves a thread up before its target within a project", () => { + const initialState = makeUiState(); + + const next = reorderThreads( + initialState, + "env-local:proj-a", + ["t-1", "t-2", "t-3"], + ["t-3"], + "t-1", + ); + + expect(next.threadOrderByProject["env-local:proj-a"]).toEqual(["t-3", "t-1", "t-2"]); + }); + + it("reorderThreads seeds order from the live list on first drag", () => { + const initialState = makeUiState(); + + const next = reorderThreads(initialState, "env-local:proj-a", ["t-1", "t-2"], ["t-2"], "t-1"); + + expect(next.threadOrderByProject["env-local:proj-a"]).toEqual(["t-2", "t-1"]); + }); + + it("reorderThreads is a no-op when dragging onto itself", () => { + const initialState = makeUiState({ + threadOrderByProject: { "env-local:proj-a": ["t-1", "t-2"] }, + }); + + const next = reorderThreads(initialState, "env-local:proj-a", ["t-1", "t-2"], ["t-1"], "t-1"); + + expect(next).toBe(initialState); + }); + + it("reorderThreads is a no-op when the target is not in the live list", () => { + const initialState = makeUiState(); + + const next = reorderThreads( + initialState, + "env-local:proj-a", + ["t-1", "t-2"], + ["t-1"], + "missing", + ); + + expect(next).toBe(initialState); + }); + it("syncThreads seeds visit state for unseen snapshot threads", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState(); @@ -568,6 +662,28 @@ describe("uiStateStore persistence round-trip", () => { expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); }); + it("persists manual thread order verbatim across restart", () => { + // Thread keys are stable, so unlike project order this round-trips with no + // id→cwd remapping: what reorderThreads stores is exactly what reloads. + const state = reorderThreads( + makeUiState(), + "env-local:proj-a", + ["t-1", "t-2", "t-3"], + ["t-3"], + "t-1", + ); + expect(state.threadOrderByProject["env-local:proj-a"]).toEqual(["t-3", "t-1", "t-2"]); + + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + expect(persisted.threadOrderByProject).toEqual({ + "env-local:proj-a": ["t-3", "t-1", "t-2"], + }); + }); + it("persists the default advertised endpoint preference", () => { const state = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http"); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index f16495bed7f..e17aa000395 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -21,6 +21,10 @@ export interface PersistedUiState { projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; threadChangedFilesExpandedById?: Record>; + // Manual thread order, keyed by sidebar project (logical) key. Thread keys are + // stable (env + threadId), so unlike `projectOrderCwds` this persists directly + // without any id→cwd remapping. + threadOrderByProject?: Record; } export interface UiProjectState { @@ -31,6 +35,8 @@ export interface UiProjectState { export interface UiThreadState { threadLastVisitedAtById: Record; threadChangedFilesExpandedById: Record>; + /** Manual thread order per sidebar project (logical) key. */ + threadOrderByProject: Record; } export interface UiEndpointState { @@ -57,6 +63,7 @@ const initialState: UiState = { projectOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, + threadOrderByProject: {}, defaultAdvertisedEndpointKey: null, }; @@ -103,12 +110,43 @@ function readPersistedState(): UiState { threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( parsed.threadChangedFilesExpandedById, ), + threadOrderByProject: sanitizePersistedThreadOrderByProject(parsed.threadOrderByProject), }; } catch { return initialState; } } +function sanitizePersistedThreadOrderByProject( + value: PersistedUiState["threadOrderByProject"], +): Record { + if (!value || typeof value !== "object") { + return {}; + } + + const nextState: Record = {}; + for (const [projectKey, threadKeys] of Object.entries(value)) { + if (!projectKey || !Array.isArray(threadKeys)) { + continue; + } + + const seen = new Set(); + const nextThreadKeys: string[] = []; + for (const threadKey of threadKeys) { + if (typeof threadKey === "string" && threadKey.length > 0 && !seen.has(threadKey)) { + seen.add(threadKey); + nextThreadKeys.push(threadKey); + } + } + + if (nextThreadKeys.length > 0) { + nextState[projectKey] = nextThreadKeys; + } + } + + return nextState; +} + function sanitizePersistedThreadChangedFilesExpanded( value: PersistedUiState["threadChangedFilesExpandedById"], ): Record> { @@ -195,6 +233,7 @@ export function persistState(state: UiState): void { projectOrderCwds, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, + threadOrderByProject: state.threadOrderByProject, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -224,10 +263,29 @@ function recordsEqual(left: Record, right: Record): boo return true; } +function stringListsEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean { - return ( - left.length === right.length && left.every((projectId, index) => projectId === right[index]) - ); + return stringListsEqual(left, right); +} + +function threadOrderByProjectEqual( + left: Record, + right: Record, +): boolean { + const leftKeys = Object.keys(left); + if (leftKeys.length !== Object.keys(right).length) { + return false; + } + for (const key of leftKeys) { + const rightValue = right[key]; + if (!rightValue || !stringListsEqual(left[key]!, rightValue)) { + return false; + } + } + return true; } function nestedBooleanRecordsEqual( @@ -427,12 +485,22 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) retainedThreadIds.has(threadId), ), ); + // Drop manual order entries for threads that no longer exist; a project whose + // every thread is gone (or that was removed entirely) collapses to no entry. + const nextThreadOrderByProject: Record = {}; + for (const [projectKey, threadKeys] of Object.entries(state.threadOrderByProject)) { + const retained = threadKeys.filter((threadKey) => retainedThreadIds.has(threadKey)); + if (retained.length > 0) { + nextThreadOrderByProject[projectKey] = retained; + } + } if ( recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) && nestedBooleanRecordsEqual( state.threadChangedFilesExpandedById, nextThreadChangedFilesExpandedById, - ) + ) && + threadOrderByProjectEqual(state.threadOrderByProject, nextThreadOrderByProject) ) { return state; } @@ -440,6 +508,7 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, + threadOrderByProject: nextThreadOrderByProject, }; } @@ -633,6 +702,65 @@ export function reorderProjects( }; } +/** + * Reorder threads within a single sidebar project (or group). `orderedThreadKeys` + * is the full, currently-displayed order — it seeds the persisted order even when + * the user has never dragged this project before. Mirrors `reorderProjects`, but + * operates on the live order rather than a pre-existing persisted array so it + * works the first time a thread is dragged. + */ +export function reorderThreads( + state: UiState, + projectKey: string, + orderedThreadKeys: readonly string[], + draggedThreadKeys: readonly string[], + targetThreadKey: string, +): UiState { + if (draggedThreadKeys.length === 0) { + return state; + } + const draggedSet = new Set(draggedThreadKeys); + if (draggedSet.has(targetThreadKey)) { + return state; + } + + const nextOrder = [...orderedThreadKeys]; + const originalTargetIndex = nextOrder.indexOf(targetThreadKey); + if (originalTargetIndex < 0) { + return state; + } + + const removed: string[] = []; + let draggedBeforeTarget = 0; + for (let i = nextOrder.length - 1; i >= 0; i--) { + if (draggedSet.has(nextOrder[i]!)) { + removed.unshift(nextOrder.splice(i, 1)[0]!); + if (i < originalTargetIndex) { + draggedBeforeTarget++; + } + } + } + if (removed.length === 0) { + return state; + } + + const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1); + nextOrder.splice(insertIndex, 0, ...removed); + + const previousOrder = state.threadOrderByProject[projectKey]; + if (previousOrder && stringListsEqual(previousOrder, nextOrder)) { + return state; + } + + return { + ...state, + threadOrderByProject: { + ...state.threadOrderByProject, + [projectKey]: nextOrder, + }, + }; +} + interface UiStateStore extends UiState { syncProjects: (projects: readonly SyncProjectInput[]) => void; syncThreads: (threads: readonly SyncThreadInput[]) => void; @@ -647,6 +775,12 @@ interface UiStateStore extends UiState { draggedProjectIds: readonly string[], targetProjectIds: readonly string[], ) => void; + reorderThreads: ( + projectKey: string, + orderedThreadKeys: readonly string[], + draggedThreadKeys: readonly string[], + targetThreadKey: string, + ) => void; } export const useUiStateStore = create((set) => ({ @@ -667,6 +801,10 @@ export const useUiStateStore = create((set) => ({ set((state) => setProjectExpanded(state, projectId, expanded)), reorderProjects: (draggedProjectIds, targetProjectIds) => set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), + reorderThreads: (projectKey, orderedThreadKeys, draggedThreadKeys, targetThreadKey) => + set((state) => + reorderThreads(state, projectKey, orderedThreadKeys, draggedThreadKeys, targetThreadKey), + ), })); useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 33781f56c94..ec089e37b45 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -17,7 +17,7 @@ export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_a export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; -export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); +export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";