diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 6c6cbac49..57bd3065d 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -5,6 +5,8 @@ import { getSlashModelOptions, normalizeCustomModelSlugs, resolveAppModelSelection, + resolveSidebarThreadOrder, + SIDEBAR_THREAD_ORDER_OPTIONS, } from "./appSettings"; describe("normalizeCustomModelSlugs", () => { @@ -82,3 +84,18 @@ describe("getSlashModelOptions", () => { expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); }); }); + +describe("resolveSidebarThreadOrder", () => { + it("defaults invalid values to recent-activity", () => { + expect(resolveSidebarThreadOrder("something-else")).toBe("recent-activity"); + }); + + it("keeps the supported thread ordering preferences", () => { + expect(resolveSidebarThreadOrder("recent-activity")).toBe("recent-activity"); + expect(resolveSidebarThreadOrder("created-at")).toBe("created-at"); + expect(SIDEBAR_THREAD_ORDER_OPTIONS.map((option) => option.value)).toEqual([ + "recent-activity", + "created-at", + ]); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5ed218fb2..88ef4e10a 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -6,6 +6,20 @@ import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/s const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; +export const SIDEBAR_THREAD_ORDER_OPTIONS = [ + { + value: "recent-activity", + label: "Recent activity", + description: "Sort chats by the latest user message, final assistant message, plan, or assistant question.", + }, + { + value: "created-at", + label: "Created time", + description: "Sort chats by when the thread was originally created.", + }, +] as const; +export type SidebarThreadOrder = (typeof SIDEBAR_THREAD_ORDER_OPTIONS)[number]["value"]; +const SidebarThreadOrderSchema = Schema.Literals(["recent-activity", "created-at"]); const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), }; @@ -21,6 +35,9 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), + sidebarThreadOrder: SidebarThreadOrderSchema.pipe( + Schema.withConstructorDefault(() => Option.some("recent-activity")), + ), customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), @@ -32,6 +49,10 @@ export interface AppModelOption { isCustom: boolean; } +export function resolveSidebarThreadOrder(value: string | null | undefined): SidebarThreadOrder { + return value === "created-at" ? "created-at" : "recent-activity"; +} + const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); let listeners: Array<() => void> = []; @@ -70,6 +91,7 @@ export function normalizeCustomModelSlugs( function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, + sidebarThreadOrder: resolveSidebarThreadOrder(settings.sidebarThreadOrder), customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), }; } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0f0250e48..eba15a62f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -8,6 +8,7 @@ import { type ProjectId, type ServerConfig, type ThreadId, + type TurnId, type WsWelcomePayload, WS_CHANNELS, WS_METHODS, @@ -256,6 +257,69 @@ function createDraftOnlySnapshot(): OrchestrationReadModel { }; } +function createSnapshotWithPendingUserInput(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-pending-input" as MessageId, + targetText: "pending input thread", + }); + const turnId = "turn-pending-user-input" as TurnId; + const pendingUserInputActivity: OrchestrationReadModel["threads"][number]["activities"][number] = { + id: "activity-pending-user-input" as OrchestrationReadModel["threads"][number]["activities"][number]["id"], + createdAt: isoAt(102), + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + turnId, + payload: { + requestId: "req-user-input-browser", + questions: [ + { + id: "affected_course", + header: "Affected Course", + question: "Which student calendar is broken?", + options: [ + { + label: "The Combine (Recommended)", + description: "Matches the report.", + }, + { + label: "The Invitational", + description: "Alternative calendar.", + }, + ], + }, + ], + }, + }; + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id !== THREAD_ID + ? thread + : Object.assign({}, thread, { + latestTurn: { + turnId, + state: "running", + requestedAt: isoAt(100), + startedAt: isoAt(101), + completedAt: null, + assistantMessageId: null, + }, + session: { + threadId: THREAD_ID, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: turnId, + lastError: null, + updatedAt: isoAt(101), + }, + activities: [pendingUserInputActivity], + }), + ), + }; +} + function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-plan-target" as MessageId, @@ -858,6 +922,43 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("submits structured user-input answers from a running thread", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithPendingUserInput(), + }); + + try { + const optionButton = page.getByRole("button", { name: /The Combine \(Recommended\)/i }); + await expect.element(optionButton).toBeVisible(); + await optionButton.click(); + + const submitButton = page.getByRole("button", { name: /Submit answers/i }); + await expect.element(submitButton).toBeEnabled(); + await submitButton.click(); + + await vi.waitFor( + () => { + const dispatches = wsRequests.filter( + (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand, + ); + const userInputCommand = dispatches + .map((request) => request.command as { type?: string; requestId?: string } | undefined) + .find((command) => command?.type === "thread.user-input.respond"); + expect(userInputCommand).toEqual( + expect.objectContaining({ + type: "thread.user-input.respond", + requestId: "req-user-input-browser", + }), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1023b37e1..cea66a10f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -873,8 +873,12 @@ export default function ChatView({ threadId }: ChatViewProps) { [threadActivities], ); const pendingUserInputs = useMemo( - () => derivePendingUserInputs(threadActivities), - [threadActivities], + () => + derivePendingUserInputs(threadActivities, { + latestTurn: activeLatestTurn, + session: activeThread?.session ?? null, + }), + [activeLatestTurn, activeThread?.session, threadActivities], ); const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d0216d0e4..8c2d9972a 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -72,10 +72,23 @@ describe("resolveThreadStatusPill", () => { hasPendingApprovals: false, hasPendingUserInput: false, }), + ).toMatchObject({ label: "Planning", pulse: true }); + }); + + it("shows working for non-plan threads that are actively running", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + interactionMode: "default", + }, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), ).toMatchObject({ label: "Working", pulse: true }); }); - it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { + it("shows plan submitted when a settled plan turn has a proposed plan ready for follow-up", () => { expect( resolveThreadStatusPill({ thread: { @@ -99,7 +112,34 @@ describe("resolveThreadStatusPill", () => { hasPendingApprovals: false, hasPendingUserInput: false, }), - ).toMatchObject({ label: "Plan Ready", pulse: false }); + ).toMatchObject({ label: "Plan Submitted", pulse: false }); + }); + + it("shows errored when the latest turn failed after the thread was last visited", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + latestTurn: { + turnId: "turn-1" as never, + state: "error", + assistantMessageId: null, + requestedAt: "2026-03-09T10:00:00.000Z", + startedAt: "2026-03-09T10:00:00.000Z", + completedAt: "2026-03-09T10:05:00.000Z", + }, + lastVisitedAt: "2026-03-09T10:04:00.000Z", + session: { + ...baseThread.session, + status: "error", + updatedAt: "2026-03-09T10:05:00.000Z", + orchestrationStatus: "error", + }, + }, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Errored", pulse: false }); }); it("shows completed when there is an unseen completion and no active blocker", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e950d8de6..0d81b740f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -4,11 +4,13 @@ import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; export interface ThreadStatusPill { label: | "Working" + | "Planning" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input" - | "Plan Ready"; + | "Plan Submitted" + | "Errored"; colorClass: string; dotClass: string; pulse: boolean; @@ -19,6 +21,28 @@ type ThreadStatusInput = Pick< "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" >; +function hasUnreadAt(timestamp: string | undefined, lastVisitedAt: string | undefined): boolean { + if (!timestamp) { + return false; + } + + const updatedAt = Date.parse(timestamp); + if (Number.isNaN(updatedAt)) { + return false; + } + + if (!lastVisitedAt) { + return true; + } + + const visitedAt = Date.parse(lastVisitedAt); + if (Number.isNaN(visitedAt)) { + return true; + } + + return updatedAt > visitedAt; +} + export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); @@ -55,6 +79,15 @@ export function resolveThreadStatusPill(input: { }; } + if (thread.session?.status === "running" && thread.interactionMode === "plan") { + return { + label: "Planning", + colorClass: "text-cyan-600 dark:text-cyan-300/90", + dotClass: "bg-cyan-500 dark:bg-cyan-300/90", + pulse: true, + }; + } + if (thread.session?.status === "running") { return { label: "Working", @@ -80,9 +113,33 @@ export function resolveThreadStatusPill(input: { findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null; if (hasPlanReadyPrompt) { return { - label: "Plan Ready", - colorClass: "text-violet-600 dark:text-violet-300/90", - dotClass: "bg-violet-500 dark:bg-violet-300/90", + label: "Plan Submitted", + colorClass: "text-teal-600 dark:text-teal-300/90", + dotClass: "bg-teal-500 dark:bg-teal-300/90", + pulse: false, + }; + } + + if ( + thread.latestTurn?.state === "error" && + hasUnreadAt(thread.latestTurn.completedAt ?? undefined, thread.lastVisitedAt) + ) { + return { + label: "Errored", + colorClass: "text-rose-600 dark:text-rose-300/90", + dotClass: "bg-rose-500 dark:bg-rose-300/90", + pulse: false, + }; + } + + if ( + thread.session?.status === "error" && + hasUnreadAt(thread.session.updatedAt, thread.lastVisitedAt) + ) { + return { + label: "Errored", + colorClass: "text-rose-600 dark:text-rose-300/90", + dotClass: "bg-rose-500 dark:bg-rose-300/90", pulse: false, }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 284ed0da9..955ac9626 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,7 +1,9 @@ import { ArrowLeftIcon, ChevronRightIcon, + CircleDotIcon, FolderIcon, + GitBranchIcon, GitPullRequestIcon, PlusIcon, RocketIcon, @@ -10,22 +12,7 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - DndContext, - type DragCancelEvent, - type CollisionDetection, - PointerSensor, - type DragStartEvent, - closestCorners, - pointerWithin, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; -import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { CSS } from "@dnd-kit/utilities"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_RUNTIME_MODE, DEFAULT_MODEL_BY_PROVIDER, @@ -42,14 +29,48 @@ import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; import { useStore } from "../store"; -import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; +import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; +import { type Thread } from "../types"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; +import { getSidebarThreadSortTimestamp, sortSidebarThreadEntries } from "../sidebarThreadOrder"; +import { buildSidebarGroupContextMenuItems } from "../sidebarGroupContextMenu"; +import { + orderProjectsByIds, + shouldClearOptimisticProjectOrder, + reorderProjectOrder, +} from "../projectOrder"; +import { + animateSidebarReorder, + buildSidebarReorderDeltas, + collectElementTopPositions, + hasSidebarReorderChanged, +} from "../sidebarReorderAnimation"; +import { preferredTerminalEditor } from "../terminal-links"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; +import { + buildProjectChildrenClassName, + buildProjectGroupCollapseKey, + buildThreadGroupChildrenClassName, + buildThreadGroupDragCursorClassName, + buildSidebarInteractionClassName, + buildThreadGroupChevronClassName, + buildThreadGroupComposeButtonClassName, + buildThreadGroupDropIndicatorClassName, + buildThreadGroupHeaderClassName, + buildThreadRowClassName, + hasCrossedThreadGroupDragThreshold, + isProjectGroupOpen, + resolveThreadGroupDropEffect, + shouldIgnoreSidebarDragPointerDown, + shouldSnapThreadGroupDropToEnd, + setProjectGroupCollapsed, + shouldRenderProjectComposeButton, +} from "./sidebarGroupInteractions"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -63,7 +84,6 @@ import { } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; -import { Collapsible, CollapsibleContent } from "./ui/collapsible"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -82,10 +102,16 @@ import { } from "./ui/sidebar"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; +import { + MAIN_THREAD_GROUP_ID, + buildThreadGroupId, + orderProjectThreadGroups, + resolveProjectThreadGroupPrById, + reorderProjectThreadGroupOrder, +} from "../threadGroups"; import { resolveThreadStatusPill } from "./Sidebar.logic"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; -const THREAD_PREVIEW_LIMIT = 6; async function copyTextToClipboard(text: string): Promise { if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) { @@ -118,6 +144,15 @@ interface PrStatusIndicator { } type ThreadPr = GitStatusResult["pr"]; +type SidebarGroupEntry = { + id: ThreadId; + title: string; + createdAt: string; + branch: string | null; + worktreePath: string | null; + thread: Thread | null; + isDraft: boolean; +}; function terminalStatusFromRunningIds( runningTerminalIds: string[], @@ -222,52 +257,31 @@ function ProjectFavicon({ cwd }: { cwd: string }) { ); } -type SortableProjectHandleProps = Pick, "attributes" | "listeners">; - -function SortableProjectItem({ - projectId, - children, -}: { - projectId: ProjectId; - children: (handleProps: SortableProjectHandleProps) => React.ReactNode; -}) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = - useSortable({ id: projectId }); - return ( -
  • - {children({ attributes, listeners })} -
  • - ); -} - export default function Sidebar() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); + const syncServerReadModel = useStore((store) => store.syncServerReadModel); const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); - const reorderProjects = useStore((store) => store.reorderProjects); + const setProjectExpanded = useStore((store) => store.setProjectExpanded); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); - const getDraftThreadByProjectId = useComposerDraftStore( - (store) => store.getDraftThreadByProjectId, + const draftsByThreadId = useComposerDraftStore((store) => store.draftsByThreadId); + const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); + const projectGroupDraftThreadIdById = useComposerDraftStore( + (store) => store.projectGroupDraftThreadIdById, + ); + const getDraftThreadByProjectGroupId = useComposerDraftStore( + (store) => store.getDraftThreadByProjectGroupId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); + const setProjectGroupDraftThreadId = useComposerDraftStore( + (store) => store.setProjectGroupDraftThreadId, + ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const clearProjectDraftThreadId = useComposerDraftStore( - (store) => store.clearProjectDraftThreadId, + const clearProjectGroupDraftThreadId = useComposerDraftStore( + (store) => store.clearProjectGroupDraftThreadId, ); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, @@ -293,16 +307,63 @@ export default function Sidebar() { const addProjectInputRef = useRef(null); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); - const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< - ReadonlySet - >(() => new Set()); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); - const dragInProgressRef = useRef(false); - const suppressProjectClickAfterDragRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); - const shouldBrowseForProjectImmediately = isElectron; - const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const [optimisticProjectOrder, setOptimisticProjectOrder] = useState(null); + const [optimisticGroupOrderByProjectId, setOptimisticGroupOrderByProjectId] = useState< + Record + >({}); + const [draggedProjectId, setDraggedProjectId] = useState(null); + const [projectDropTarget, setProjectDropTarget] = useState<{ beforeProjectId: ProjectId | null } | null>( + null, + ); + const [draggedGroup, setDraggedGroup] = useState<{ projectId: ProjectId; groupId: string } | null>( + null, + ); + const [dropTarget, setDropTarget] = useState<{ projectId: ProjectId; beforeGroupId: string | null } | null>( + null, + ); + const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set()); + const pendingProjectDragRef = useRef<{ + projectId: ProjectId; + startX: number; + startY: number; + pointerId: number; + element: HTMLElement; + } | null>(null); + const projectRowRefs = useRef(new Map()); + const threadGroupRowRefs = useRef(new Map()); + const previousProjectOrderRef = useRef>([]); + const previousProjectTopsRef = useRef>(new Map()); + const pendingProjectAnimationStartTopsRef = useRef | null>(null); + const previousGroupOrderByProjectRef = useRef(new Map>()); + const previousGroupTopsRef = useRef>(new Map()); + const pendingGroupAnimationStartTopsRef = useRef | null>(null); + const activeDraggedProjectRef = useRef(null); + const pendingGroupDragRef = useRef<{ + projectId: ProjectId; + groupId: string; + startX: number; + startY: number; + pointerId: number; + element: HTMLDivElement; + } | null>(null); + const activeDraggedGroupRef = useRef<{ projectId: ProjectId; groupId: string } | null>(null); + const suppressProjectClickRef = useRef(null); + const suppressGroupClickRef = useRef(null); + const releasePendingGroupPointerCapture = useCallback(() => { + const pendingDrag = pendingGroupDragRef.current; + if (!pendingDrag) return; + if (pendingDrag.element.hasPointerCapture(pendingDrag.pointerId)) { + pendingDrag.element.releasePointerCapture(pendingDrag.pointerId); + } + }, []); + const setGroupOpen = useCallback((projectId: ProjectId, groupId: string, open: boolean) => { + setCollapsedGroupIds((prev) => + setProjectGroupCollapsed(prev, buildProjectGroupCollapseKey(projectId, groupId), open), + ); + }, []); const pendingApprovalByThreadId = useMemo(() => { const map = new Map(); for (const thread of threads) { @@ -321,58 +382,97 @@ export default function Sidebar() { () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); - const threadGitTargets = useMemo( + const orderedProjects = useMemo( + () => orderProjectsByIds(projects, optimisticProjectOrder), + [optimisticProjectOrder, projects], + ); + const orderedGroupIdsByProjectId = useMemo(() => { + const draftThreadsByProjectId = new Map>(); + for (const [threadId, draftThread] of Object.entries(draftThreadsByThreadId)) { + const existingDraftThreads = draftThreadsByProjectId.get(draftThread.projectId) ?? []; + existingDraftThreads.push({ + id: threadId as ThreadId, + createdAt: draftThread.createdAt, + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + }); + draftThreadsByProjectId.set(draftThread.projectId, existingDraftThreads); + } + + const groupIdsByProjectId = new Map>(); + for (const project of orderedProjects) { + const threadEntries = threads + .filter((thread) => thread.projectId === project.id) + .map((thread) => ({ + id: thread.id, + createdAt: thread.createdAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + })); + const draftEntries = draftThreadsByProjectId.get(project.id) ?? []; + + groupIdsByProjectId.set( + project.id, + orderProjectThreadGroups({ + threads: [...threadEntries, ...draftEntries], + orderedGroupIds: optimisticGroupOrderByProjectId[project.id], + }).map((group) => group.id), + ); + } + + return groupIdsByProjectId; + }, [draftThreadsByThreadId, optimisticGroupOrderByProjectId, orderedProjects, threads]); + const gitStatusTargets = useMemo( () => - threads.map((thread) => ({ - threadId: thread.id, - branch: thread.branch, - cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, - })), - [projectCwdById, threads], + [ + ...threads.map((thread) => ({ + branch: thread.branch, + cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, + })), + ...Object.values(draftThreadsByThreadId).map((draftThread) => ({ + branch: draftThread.branch, + cwd: draftThread.worktreePath ?? projectCwdById.get(draftThread.projectId) ?? null, + })), + ], + [draftThreadsByThreadId, projectCwdById, threads], ); - const threadGitStatusCwds = useMemo( + const gitStatusCwds = useMemo( () => [ ...new Set( - threadGitTargets + gitStatusTargets .filter((target) => target.branch !== null) .map((target) => target.cwd) .filter((cwd): cwd is string => cwd !== null), ), ], - [threadGitTargets], + [gitStatusTargets], ); const threadGitStatusQueries = useQueries({ - queries: threadGitStatusCwds.map((cwd) => ({ + queries: gitStatusCwds.map((cwd) => ({ ...gitStatusQueryOptions(cwd), staleTime: 30_000, refetchInterval: 60_000, })), }); - const prByThreadId = useMemo(() => { + const gitStatusByCwd = useMemo(() => { const statusByCwd = new Map(); - for (let index = 0; index < threadGitStatusCwds.length; index += 1) { - const cwd = threadGitStatusCwds[index]; + for (let index = 0; index < gitStatusCwds.length; index += 1) { + const cwd = gitStatusCwds[index]; if (!cwd) continue; const status = threadGitStatusQueries[index]?.data; if (status) { statusByCwd.set(cwd, status); } } + return statusByCwd; + }, [gitStatusCwds, threadGitStatusQueries]); - const map = new Map(); - for (const target of threadGitTargets) { - const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; - const branchMatches = - target.branch !== null && status?.branch !== null && status?.branch === target.branch; - map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); - } - return map; - }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); - - const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { - event.preventDefault(); - event.stopPropagation(); - + const openPrUrl = useCallback((prUrl: string) => { const api = readNativeApi(); if (!api) { toastManager.add({ @@ -391,6 +491,88 @@ export default function Sidebar() { }); }, []); + useEffect(() => { + if ( + !shouldClearOptimisticProjectOrder({ + optimisticOrder: optimisticProjectOrder, + currentOrder: projects.map((project) => project.id), + }) + ) { + return; + } + setOptimisticProjectOrder(null); + }, [optimisticProjectOrder, projects]); + + useLayoutEffect(() => { + if (typeof window === "undefined") { + return; + } + + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const nextProjectOrder = orderedProjects.map((project) => project.id); + const nextProjectTops = collectElementTopPositions(projectRowRefs.current); + const projectAnimationStartTops = + pendingProjectAnimationStartTopsRef.current ?? previousProjectTopsRef.current; + + if ( + !prefersReducedMotion && + hasSidebarReorderChanged(previousProjectOrderRef.current, nextProjectOrder) + ) { + animateSidebarReorder( + projectRowRefs.current, + buildSidebarReorderDeltas(projectAnimationStartTops, nextProjectTops), + ); + } + + previousProjectOrderRef.current = nextProjectOrder; + previousProjectTopsRef.current = nextProjectTops; + pendingProjectAnimationStartTopsRef.current = null; + + const nextGroupTops = collectElementTopPositions(threadGroupRowRefs.current); + const groupAnimationStartTops = + pendingGroupAnimationStartTopsRef.current ?? previousGroupTopsRef.current; + const changedProjectIds = new Set(); + for (const [projectId, nextGroupOrder] of orderedGroupIdsByProjectId.entries()) { + const previousGroupOrder = previousGroupOrderByProjectRef.current.get(projectId) ?? []; + if (hasSidebarReorderChanged(previousGroupOrder, nextGroupOrder)) { + changedProjectIds.add(projectId); + } + } + + if (!prefersReducedMotion && changedProjectIds.size > 0) { + const previousGroupTops = new Map( + [...groupAnimationStartTops.entries()].filter(([key]) => + changedProjectIds.has(key.split("\u0000", 1)[0] ?? ""), + ), + ); + const reorderedGroupTops = new Map( + [...nextGroupTops.entries()].filter(([key]) => + changedProjectIds.has(key.split("\u0000", 1)[0] ?? ""), + ), + ); + + animateSidebarReorder( + threadGroupRowRefs.current, + buildSidebarReorderDeltas(previousGroupTops, reorderedGroupTops), + ); + } + + previousGroupOrderByProjectRef.current = new Map( + [...orderedGroupIdsByProjectId.entries()].map(([projectId, groupIds]) => [projectId, [...groupIds]]), + ); + previousGroupTopsRef.current = nextGroupTops; + pendingGroupAnimationStartTopsRef.current = null; + }, [orderedGroupIdsByProjectId, orderedProjects]); + + const openPrLink = useCallback( + (event: React.MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); + openPrUrl(prUrl); + }, + [openPrUrl], + ); + const handleNewThread = useCallback( ( projectId: ProjectId, @@ -400,10 +582,14 @@ export default function Sidebar() { envMode?: DraftThreadEnvMode; }, ): Promise => { + const groupId = buildThreadGroupId({ + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, + }); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; - const storedDraftThread = getDraftThreadByProjectId(projectId); + const storedDraftThread = getDraftThreadByProjectGroupId(projectId, groupId); if (storedDraftThread) { return (async () => { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { @@ -413,7 +599,7 @@ export default function Sidebar() { ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } - setProjectDraftThreadId(projectId, storedDraftThread.threadId); + setProjectGroupDraftThreadId(projectId, groupId, storedDraftThread.threadId); if (routeThreadId === storedDraftThread.threadId) { return; } @@ -423,10 +609,18 @@ export default function Sidebar() { }); })(); } - clearProjectDraftThreadId(projectId); + clearProjectGroupDraftThreadId(projectId, groupId); const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; - if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { + if ( + activeDraftThread && + routeThreadId && + activeDraftThread.projectId === projectId && + buildThreadGroupId({ + branch: activeDraftThread.branch, + worktreePath: activeDraftThread.worktreePath, + }) === groupId + ) { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { setDraftThreadContext(routeThreadId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), @@ -434,17 +628,17 @@ export default function Sidebar() { ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } - setProjectDraftThreadId(projectId, routeThreadId); + setProjectGroupDraftThreadId(projectId, groupId, routeThreadId); return Promise.resolve(); } const threadId = newThreadId(); const createdAt = new Date().toISOString(); return (async () => { - setProjectDraftThreadId(projectId, threadId, { + setProjectGroupDraftThreadId(projectId, groupId, threadId, { createdAt, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, - envMode: options?.envMode ?? "local", + envMode: options?.envMode ?? (groupId === MAIN_THREAD_GROUP_ID ? "local" : "worktree"), runtimeMode: DEFAULT_RUNTIME_MODE, }); @@ -455,13 +649,13 @@ export default function Sidebar() { })(); }, [ - clearProjectDraftThreadId, - getDraftThreadByProjectId, + clearProjectGroupDraftThreadId, + getDraftThreadByProjectGroupId, navigate, getDraftThread, routeThreadId, setDraftThreadContext, - setProjectDraftThreadId, + setProjectGroupDraftThreadId, ], ); @@ -521,37 +715,21 @@ export default function Sidebar() { }); await handleNewThread(projectId).catch(() => undefined); } catch (error) { - const description = - error instanceof Error ? error.message : "An error occurred while adding the project."; setIsAddingProject(false); - if (shouldBrowseForProjectImmediately) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description, - }); - } else { - setAddProjectError(description); - } + setAddProjectError( + error instanceof Error ? error.message : "An error occurred while adding the project.", + ); return; } finishAddingProject(); }, - [ - focusMostRecentThreadForProject, - handleNewThread, - isAddingProject, - projects, - shouldBrowseForProjectImmediately, - ], + [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects], ); const handleAddProject = () => { void addProjectFromPath(newCwd); }; - const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - const handlePickFolder = async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; @@ -564,21 +742,12 @@ export default function Sidebar() { } if (pickedPath) { await addProjectFromPath(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { + } else { addProjectInputRef.current?.focus(); } setIsPickingFolder(false); }; - const handleStartAddProject = () => { - setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { - void handlePickFolder(); - return; - } - setAddingProject((prev) => !prev); - }; - const cancelRename = useCallback(() => { setRenamingThreadId(null); renamingInputRef.current = null; @@ -728,6 +897,12 @@ export default function Sidebar() { commandId: newCommandId(), threadId, }); + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); clearComposerDraftForThread(threadId); clearProjectDraftThreadById(thread.projectId, thread.id); clearTerminalState(threadId); @@ -778,6 +953,7 @@ export default function Sidebar() { projects, removeWorktreeMutation, routeThreadId, + syncServerReadModel, threads, ], ); @@ -811,16 +987,24 @@ export default function Sidebar() { if (!confirmed) return; try { - const projectDraftThread = getDraftThreadByProjectId(projectId); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.threadId); + const projectPrefix = `${projectId}\u0000`; + for (const [mappingId, threadId] of Object.entries(projectGroupDraftThreadIdById)) { + if (!mappingId.startsWith(projectPrefix)) { + continue; + } + clearComposerDraftForThread(threadId as ThreadId); } - clearProjectDraftThreadId(projectId); await api.orchestration.dispatchCommand({ type: "project.delete", commandId: newCommandId(), projectId, }); + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error deleting project."; console.error("Failed to remove project", { projectId, error }); @@ -833,82 +1017,220 @@ export default function Sidebar() { }, [ clearComposerDraftForThread, - clearProjectDraftThreadId, - getDraftThreadByProjectId, + projectGroupDraftThreadIdById, projects, + syncServerReadModel, threads, ], ); - const projectDnDSensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 6 }, - }), - ); - const projectCollisionDetection = useCallback((args) => { - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) { - return pointerCollisions; - } + const handleGroupContextMenu = useCallback( + async ( + input: { + projectId: ProjectId; + projectCwd: string; + groupId: string; + groupLabel: string; + branch: string | null; + worktreePath: string | null; + prUrl: string | null; + entries: SidebarGroupEntry[]; + }, + position: { x: number; y: number }, + ) => { + const api = readNativeApi(); + if (!api) return; - return closestCorners(args); - }, []); + const workspacePath = input.worktreePath ?? input.projectCwd; + const clicked = await api.contextMenu.show( + buildSidebarGroupContextMenuItems({ + isMainGroup: input.groupId === MAIN_THREAD_GROUP_ID, + hasBranch: input.branch !== null, + hasWorktreePath: input.worktreePath !== null, + hasPr: input.prUrl !== null, + }), + position, + ); + if (!clicked) return; - const handleProjectDragEnd = useCallback( - (event: DragEndEvent) => { - dragInProgressRef.current = false; - const { active, over } = event; - if (!over || active.id === over.id) return; - const activeProject = projects.find((project) => project.id === active.id); - const overProject = projects.find((project) => project.id === over.id); - if (!activeProject || !overProject) return; - reorderProjects(activeProject.id, overProject.id); - }, - [projects, reorderProjects], - ); + if (clicked === "open-workspace") { + void api.shell.openInEditor(workspacePath, preferredTerminalEditor()).catch((error) => { + toastManager.add({ + type: "error", + title: "Failed to open workspace", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + return; + } - const handleProjectDragStart = useCallback((_event: DragStartEvent) => { - dragInProgressRef.current = true; - suppressProjectClickAfterDragRef.current = true; - }, []); + if (clicked === "copy-workspace-path" || clicked === "copy-project-path") { + try { + await copyTextToClipboard(clicked === "copy-workspace-path" ? workspacePath : input.projectCwd); + toastManager.add({ + type: "success", + title: "Path copied", + description: clicked === "copy-workspace-path" ? workspacePath : input.projectCwd, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return; + } - const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { - dragInProgressRef.current = false; - }, []); + if (clicked === "copy-branch-name") { + if (!input.branch) return; + try { + await copyTextToClipboard(input.branch); + toastManager.add({ + type: "success", + title: "Branch copied", + description: input.branch, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to copy branch", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return; + } - const handleProjectTitlePointerDownCapture = useCallback(() => { - suppressProjectClickAfterDragRef.current = false; - }, []); + if (clicked === "open-pr") { + if (!input.prUrl) return; + openPrUrl(input.prUrl); + return; + } - const handleProjectTitleClick = useCallback( - (event: React.MouseEvent, projectId: ProjectId) => { - if (dragInProgressRef.current) { - event.preventDefault(); - event.stopPropagation(); + if (clicked === "new-chat") { + void handleNewThread(input.projectId, { + branch: input.branch, + worktreePath: input.worktreePath, + envMode: input.groupId === MAIN_THREAD_GROUP_ID ? "local" : "worktree", + }); return; } - if (suppressProjectClickAfterDragRef.current) { - // Consume the synthetic click emitted after a drag release. - suppressProjectClickAfterDragRef.current = false; - event.preventDefault(); - event.stopPropagation(); + + if (clicked !== "delete-group-worktree-and-chats" || !input.worktreePath) { return; } - toggleProject(projectId); - }, - [toggleProject], - ); - const handleProjectTitleKeyDown = useCallback( - (event: React.KeyboardEvent, projectId: ProjectId) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (dragInProgressRef.current) { + const confirmed = await api.dialogs.confirm( + [ + `Delete all chats in "${input.groupLabel}" and remove its worktree?`, + input.worktreePath, + "", + "This action cannot be undone.", + ].join("\n"), + ); + if (!confirmed) return; + + const deletedEntryIds = new Set(input.entries.map((entry) => entry.id)); + const serverEntries = input.entries.filter((entry) => entry.thread !== null); + const remainingDraftThreadId = + Object.values(projectGroupDraftThreadIdById).find( + (threadId) => !deletedEntryIds.has(threadId as ThreadId) && draftThreadsByThreadId[threadId as ThreadId], + ) ?? null; + const remainingServerThreadId = + threads.find((thread) => !deletedEntryIds.has(thread.id))?.id ?? null; + + try { + for (const entry of serverEntries) { + const thread = entry.thread; + if (!thread) continue; + if (thread.session && thread.session.status !== "closed") { + await api.orchestration + .dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId: thread.id, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); + } + + await api.terminal + .close({ + threadId: thread.id, + deleteHistory: true, + }) + .catch(() => undefined); + + await api.orchestration.dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: thread.id, + }); + clearComposerDraftForThread(thread.id); + clearProjectDraftThreadById(input.projectId, thread.id); + clearTerminalState(thread.id); + } + + clearProjectGroupDraftThreadId(input.projectId, input.groupId); + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); + } catch (error) { + toastManager.add({ + type: "error", + title: `Failed to delete "${input.groupLabel}"`, + description: error instanceof Error ? error.message : "An error occurred.", + }); return; } - toggleProject(projectId); + + if (routeThreadId && deletedEntryIds.has(routeThreadId)) { + const fallbackThreadId = remainingServerThreadId ?? remainingDraftThreadId; + if (fallbackThreadId) { + void navigate({ + to: "/$threadId", + params: { threadId: fallbackThreadId as ThreadId }, + replace: true, + }); + } else { + void navigate({ to: "/", replace: true }); + } + } + + try { + await removeWorktreeMutation.mutateAsync({ + cwd: input.projectCwd, + path: input.worktreePath, + force: true, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Chats deleted, but worktree removal failed", + description: `Could not remove ${input.worktreePath}. ${ + error instanceof Error ? error.message : "Unknown error removing worktree." + }`, + }); + } }, - [toggleProject], + [ + clearComposerDraftForThread, + clearProjectDraftThreadById, + clearProjectGroupDraftThreadId, + clearTerminalState, + draftThreadsByThreadId, + handleNewThread, + navigate, + openPrUrl, + projectGroupDraftThreadIdById, + removeWorktreeMutation, + routeThreadId, + syncServerReadModel, + threads, + ], ); useEffect(() => { @@ -976,6 +1298,21 @@ export default function Sidebar() { }; }, []); + useEffect(() => { + if (typeof document === "undefined") return; + if (!draggedGroup && !draggedProjectId) return; + + const previousBodyCursor = document.body.style.cursor; + const previousDocumentCursor = document.documentElement.style.cursor; + document.body.style.cursor = "grabbing"; + document.documentElement.style.cursor = "grabbing"; + + return () => { + document.body.style.cursor = previousBodyCursor; + document.documentElement.style.cursor = previousDocumentCursor; + }; + }, [draggedGroup, draggedProjectId]); + const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); const desktopUpdateTooltip = desktopUpdateState @@ -1003,13 +1340,6 @@ export default function Sidebar() { : shouldHighlightDesktopUpdateError(desktopUpdateState) ? "text-rose-500 animate-pulse" : "text-amber-500 animate-pulse"; - const newThreadShortcutLabel = useMemo( - () => - shortcutLabelForCommand(keybindings, "chat.newLocal") ?? - shortcutLabelForCommand(keybindings, "chat.new"), - [keybindings], - ); - const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; @@ -1068,23 +1398,370 @@ export default function Sidebar() { } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - const expandThreadListForProject = useCallback((projectId: ProjectId) => { - setExpandedThreadListsByProject((current) => { - if (current.has(projectId)) return current; - const next = new Set(current); - next.add(projectId); - return next; - }); - }, []); + const handleProjectGroupReorder = useCallback( + (projectId: ProjectId, movedGroupId: string, beforeGroupId: string | null) => { + const project = orderedProjects.find((entry) => entry.id === projectId); + if (!project || movedGroupId === MAIN_THREAD_GROUP_ID) { + return; + } + const visibleGroupIds = orderProjectThreadGroups({ + threads: [ + ...threads.filter((thread) => thread.projectId === projectId), + ...Object.entries(projectGroupDraftThreadIdById) + .flatMap(([mappingId, threadId]) => { + const separatorIndex = mappingId.indexOf("\u0000"); + if (separatorIndex <= 0) { + return []; + } + const mappingProjectId = mappingId.slice(0, separatorIndex); + if (mappingProjectId !== projectId) { + return []; + } + const draftThread = draftThreadsByThreadId[threadId as ThreadId]; + if (!draftThread) { + return []; + } + return [ + { + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + createdAt: draftThread.createdAt, + }, + ]; + }), + ], + orderedGroupIds: optimisticGroupOrderByProjectId[projectId], + }).map((group) => group.id); + + setOptimisticGroupOrderByProjectId((prev) => ({ + ...prev, + [projectId]: reorderProjectThreadGroupOrder({ + currentOrder: visibleGroupIds.filter((groupId) => groupId !== MAIN_THREAD_GROUP_ID), + movedGroupId, + beforeGroupId, + }), + })); + }, + [draftThreadsByThreadId, optimisticGroupOrderByProjectId, orderedProjects, projectGroupDraftThreadIdById, threads], + ); - const collapseThreadListForProject = useCallback((projectId: ProjectId) => { - setExpandedThreadListsByProject((current) => { - if (!current.has(projectId)) return current; - const next = new Set(current); - next.delete(projectId); - return next; - }); - }, []); + const handleProjectReorder = useCallback( + (nextOrder: ProjectId[]) => { + setOptimisticProjectOrder(nextOrder); + }, + [], + ); + + useEffect(() => { + if (typeof document === "undefined") return; + + const onPointerMove = (event: PointerEvent) => { + const pendingDrag = pendingProjectDragRef.current; + if (!pendingDrag) { + return; + } + + if (event.pointerId !== pendingDrag.pointerId) { + return; + } + + if ( + !hasCrossedThreadGroupDragThreshold({ + startX: pendingDrag.startX, + startY: pendingDrag.startY, + currentX: event.clientX, + currentY: event.clientY, + thresholdPx: 4, + }) + ) { + return; + } + + if (activeDraggedProjectRef.current === null) { + activeDraggedProjectRef.current = pendingDrag.projectId; + suppressProjectClickRef.current = pendingDrag.projectId; + setDraggedProjectId(pendingDrag.projectId); + } + + window.getSelection?.()?.removeAllRanges(); + + const hoveredElement = document.elementFromPoint(event.clientX, event.clientY); + const projectSurface = hoveredElement?.closest("[data-project-drop-surface]"); + if (projectSurface) { + const targetProjectId = projectSurface.dataset.projectId as ProjectId | undefined; + if (!targetProjectId || targetProjectId === pendingDrag.projectId) { + setProjectDropTarget(null); + return; + } + setProjectDropTarget({ beforeProjectId: targetProjectId }); + return; + } + + const endSurface = hoveredElement?.closest("[data-project-drop-end]"); + if (endSurface) { + const lastProjectId = endSurface.dataset.lastProjectId as ProjectId | undefined; + setProjectDropTarget( + lastProjectId && lastProjectId === pendingDrag.projectId ? null : { beforeProjectId: null }, + ); + return; + } + + const dropContainer = document.querySelector("[data-project-drop-container]"); + if (dropContainer) { + const containerRect = dropContainer.getBoundingClientRect(); + const containerEndSurface = + dropContainer.querySelector("[data-project-drop-end]"); + const endSurfaceRect = containerEndSurface?.getBoundingClientRect(); + const shouldSnapToEnd = shouldSnapThreadGroupDropToEnd({ + pointerX: event.clientX, + pointerY: event.clientY, + left: containerRect.left, + right: containerRect.right, + bottom: containerRect.bottom, + snapStartY: endSurfaceRect?.top ?? containerRect.bottom - 24, + thresholdPx: 80, + }); + if (shouldSnapToEnd) { + const lastProjectId = dropContainer.dataset.lastProjectId as ProjectId | undefined; + setProjectDropTarget( + lastProjectId && lastProjectId === pendingDrag.projectId ? null : { beforeProjectId: null }, + ); + return; + } + } + + setProjectDropTarget(null); + }; + + const finishProjectPointerDrag = (pointerId: number | null, canceled: boolean) => { + const pendingDrag = pendingProjectDragRef.current; + if (pendingDrag && pointerId !== null && pendingDrag.pointerId !== pointerId) { + return; + } + + if (pendingDrag?.element.hasPointerCapture(pendingDrag.pointerId)) { + pendingDrag.element.releasePointerCapture(pendingDrag.pointerId); + } + + const draggedProjectId = activeDraggedProjectRef.current; + pendingProjectDragRef.current = null; + activeDraggedProjectRef.current = null; + + if (!draggedProjectId) { + return; + } + + const nextDropTarget = projectDropTarget; + setDraggedProjectId(null); + setProjectDropTarget(null); + + if (canceled || !nextDropTarget) { + return; + } + + const nextProjectOrder = reorderProjectOrder({ + currentOrder: orderedProjects.map((project) => project.id), + movedProjectId: draggedProjectId, + beforeProjectId: nextDropTarget.beforeProjectId, + }); + setOptimisticProjectOrder(nextProjectOrder); + pendingProjectAnimationStartTopsRef.current = collectElementTopPositions(projectRowRefs.current); + handleProjectReorder(nextProjectOrder); + }; + + const onPointerUp = (event: PointerEvent) => { + finishProjectPointerDrag(event.pointerId, false); + }; + + const onPointerCancel = (event: PointerEvent) => { + finishProjectPointerDrag(event.pointerId, true); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerCancel); + return () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerCancel); + const pendingDrag = pendingProjectDragRef.current; + if (pendingDrag?.element.hasPointerCapture(pendingDrag.pointerId)) { + pendingDrag.element.releasePointerCapture(pendingDrag.pointerId); + } + }; + }, [handleProjectReorder, orderedProjects, projectDropTarget]); + + useEffect(() => { + if (typeof document === "undefined") return; + + const onPointerMove = (event: PointerEvent) => { + const pendingDrag = pendingGroupDragRef.current; + if (!pendingDrag) { + return; + } + + if (event.pointerId !== pendingDrag.pointerId) { + return; + } + + if ( + !hasCrossedThreadGroupDragThreshold({ + startX: pendingDrag.startX, + startY: pendingDrag.startY, + currentX: event.clientX, + currentY: event.clientY, + thresholdPx: 4, + }) + ) { + return; + } + + if (activeDraggedGroupRef.current === null) { + activeDraggedGroupRef.current = { + projectId: pendingDrag.projectId, + groupId: pendingDrag.groupId, + }; + suppressGroupClickRef.current = buildProjectGroupCollapseKey( + pendingDrag.projectId, + pendingDrag.groupId, + ); + setDraggedGroup(activeDraggedGroupRef.current); + } + + window.getSelection?.()?.removeAllRanges(); + + const hoveredElement = document.elementFromPoint(event.clientX, event.clientY); + const groupSurface = hoveredElement?.closest("[data-thread-group-drop-surface]"); + if (groupSurface) { + const targetProjectId = groupSurface.dataset.projectId; + const targetGroupId = groupSurface.dataset.groupId; + if (!targetProjectId || !targetGroupId) { + setDropTarget(null); + return; + } + const dropEffect = resolveThreadGroupDropEffect({ + draggedProjectId: pendingDrag.projectId, + targetProjectId, + draggedGroupId: pendingDrag.groupId, + targetGroupId, + lastGroupId: groupSurface.dataset.lastGroupId || null, + }); + setDropTarget( + dropEffect === "move" + ? { projectId: targetProjectId as ProjectId, beforeGroupId: targetGroupId } + : null, + ); + return; + } + + const endSurface = hoveredElement?.closest("[data-thread-group-drop-end]"); + if (endSurface) { + const targetProjectId = endSurface.dataset.projectId; + if (!targetProjectId) { + setDropTarget(null); + return; + } + const dropEffect = resolveThreadGroupDropEffect({ + draggedProjectId: pendingDrag.projectId, + targetProjectId, + draggedGroupId: pendingDrag.groupId, + targetGroupId: null, + lastGroupId: endSurface.dataset.lastGroupId || null, + }); + setDropTarget( + dropEffect === "move" + ? { projectId: targetProjectId as ProjectId, beforeGroupId: null } + : null, + ); + return; + } + + const dropContainer = document.querySelector( + `[data-thread-group-drop-container][data-project-id="${pendingDrag.projectId}"]`, + ); + if (dropContainer) { + const containerRect = dropContainer.getBoundingClientRect(); + const endSurface = dropContainer.querySelector("[data-thread-group-drop-end]"); + const endSurfaceRect = endSurface?.getBoundingClientRect(); + const shouldSnapToEnd = shouldSnapThreadGroupDropToEnd({ + pointerX: event.clientX, + pointerY: event.clientY, + left: containerRect.left, + right: containerRect.right, + bottom: containerRect.bottom, + snapStartY: endSurfaceRect?.top ?? containerRect.bottom - 24, + thresholdPx: 80, + }); + if (shouldSnapToEnd) { + const dropEffect = resolveThreadGroupDropEffect({ + draggedProjectId: pendingDrag.projectId, + targetProjectId: pendingDrag.projectId, + draggedGroupId: pendingDrag.groupId, + targetGroupId: null, + lastGroupId: dropContainer.dataset.lastGroupId || null, + }); + setDropTarget( + dropEffect === "move" + ? { projectId: pendingDrag.projectId, beforeGroupId: null } + : null, + ); + return; + } + } + + setDropTarget(null); + }; + + const finishGroupPointerDrag = (pointerId: number | null, canceled: boolean) => { + const pendingDrag = pendingGroupDragRef.current; + if (pendingDrag && pointerId !== null && pendingDrag.pointerId !== pointerId) { + return; + } + + releasePendingGroupPointerCapture(); + + const dragged = activeDraggedGroupRef.current; + pendingGroupDragRef.current = null; + activeDraggedGroupRef.current = null; + + if (!dragged) { + return; + } + + const nextDropTarget = dropTarget; + setDraggedGroup(null); + setDropTarget(null); + + if (canceled) { + return; + } + + if (!nextDropTarget || nextDropTarget.projectId !== dragged.projectId) { + return; + } + + pendingGroupAnimationStartTopsRef.current = collectElementTopPositions(threadGroupRowRefs.current); + void handleProjectGroupReorder(dragged.projectId, dragged.groupId, nextDropTarget.beforeGroupId); + }; + + const onPointerUp = (event: PointerEvent) => { + finishGroupPointerDrag(event.pointerId, false); + }; + + const onPointerCancel = (event: PointerEvent) => { + finishGroupPointerDrag(event.pointerId, true); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerCancel); + return () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerCancel); + releasePendingGroupPointerCapture(); + }; + }, [dropTarget, handleProjectGroupReorder, releasePendingGroupPointerCapture]); const wordmark = (
    @@ -1134,7 +1811,11 @@ export default function Sidebar() { )} - + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( @@ -1169,23 +1850,21 @@ export default function Sidebar() {
    - {shouldShowProjectPathEntry && ( + {addingProject && (
    {isElectron && ( @@ -1250,281 +1929,584 @@ export default function Sidebar() {
    )} - - - project.id)} - strategy={verticalListSortingStrategy} - > - {projects.map((project) => { - const projectThreads = threads - .filter((thread) => thread.projectId === project.id) - .toSorted((a, b) => { - const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - if (byDate !== 0) return byDate; - return b.id.localeCompare(a.id); - }); - const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; - const visibleThreads = - hasHiddenThreads && !isThreadListExpanded - ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) - : projectThreads; - - return ( - - {(dragHandleProps) => ( - -
    - handleProjectTitleClick(event, project.id)} - onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} - onContextMenu={(event) => { - event.preventDefault(); - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - - - - {project.name} - - - - { + const projectThreads = threads + .filter((thread) => thread.projectId === project.id) + .toSorted((a, b) => { + const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + if (byDate !== 0) return byDate; + return b.id.localeCompare(a.id); + }); + const draftThreadIdsForProject = Object.entries(projectGroupDraftThreadIdById) + .filter(([mappingId]) => mappingId.startsWith(`${project.id}\u0000`)) + .map(([, threadId]) => threadId as ThreadId); + const draftEntries = draftThreadIdsForProject.flatMap((threadId) => { + if (projectThreads.some((thread) => thread.id === threadId)) { + return []; + } + const draftThread = draftThreadsByThreadId[threadId]; + if (!draftThread || draftThread.projectId !== project.id) { + return []; + } + return [ + { + id: threadId, + title: + draftsByThreadId[threadId]?.prompt.trim().split("\n")[0]?.slice(0, 60) || + "New thread", + createdAt: draftThread.createdAt, + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + thread: null, + isDraft: true, + }, + ]; + }); + const projectEntries: SidebarGroupEntry[] = [ + ...projectThreads.map((thread) => ({ + id: thread.id, + title: thread.title, + createdAt: thread.createdAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + thread, + isDraft: false, + })), + ...draftEntries, + ]; + const orderedGroups = orderProjectThreadGroups({ + threads: projectEntries, + orderedGroupIds: optimisticGroupOrderByProjectId[project.id], + }); + const groupPrById = resolveProjectThreadGroupPrById({ + groups: orderedGroups, + projectCwd: project.cwd, + statusByCwd: gitStatusByCwd, + }); + const isAnySidebarDragged = draggedGroup !== null || draggedProjectId !== null; + const isDraggedProject = draggedProjectId === project.id; + const isProjectDropTarget = projectDropTarget?.beforeProjectId === project.id; + + return ( +
    { + if (element) { + projectRowRefs.current.set(project.id, element); + } else { + projectRowRefs.current.delete(project.id); + } + }} + className="group/project relative mb-1 rounded-lg" + data-project-drop-surface="true" + data-project-id={project.id} + > +
    + +
    + { + if (suppressProjectClickRef.current === project.id) { + suppressProjectClickRef.current = null; + return; + } + if (isAnySidebarDragged) return; + toggleProject(project.id); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if (isAnySidebarDragged) return; + toggleProject(project.id); + }} + onPointerDown={(event) => { + if (draggedGroup !== null || event.button !== 0) return; + if ( + shouldIgnoreSidebarDragPointerDown({ + currentTarget: event.currentTarget, + target: event.target, + }) + ) { + return; + } + event.currentTarget.setPointerCapture(event.pointerId); + pendingProjectDragRef.current = { + projectId: project.id, + startX: event.clientX, + startY: event.clientY, + pointerId: event.pointerId, + element: event.currentTarget, + }; + }} + onContextMenu={(event) => { + event.preventDefault(); + void handleProjectContextMenu(project.id, { + x: event.clientX, + y: event.clientY, + }); + }} + > + + + + {project.name} + + + {shouldRenderProjectComposeButton() ? ( + + + } + showOnHover + className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(project.id); + }} + > + + + } + /> + New thread + + ) : null} +
    + +
    +
    + + {orderedGroups.map((group) => { + const groupEntries = sortSidebarThreadEntries( + projectEntries.filter( + (entry) => + buildThreadGroupId({ + branch: entry.branch, + worktreePath: entry.worktreePath, + }) === group.id, + ), + appSettings.sidebarThreadOrder, + ); + const canDragGroup = group.id !== MAIN_THREAD_GROUP_ID; + const isAnyGroupDragged = isAnySidebarDragged; + const isDraggedGroup = + draggedGroup?.projectId === project.id && draggedGroup.groupId === group.id; + const lastGroupId = orderedGroups.at(-1)?.id ?? null; + const isGroupOpen = isProjectGroupOpen( + collapsedGroupIds, + project.id, + group.id, + ); + const groupPrStatus = prStatusIndicator(groupPrById.get(group.id) ?? null); + const isValidDropTarget = + dropTarget?.projectId === project.id && dropTarget.beforeGroupId === group.id; + const groupInteractionKey = buildProjectGroupCollapseKey(project.id, group.id); + + return ( +
    { + if (element) { + threadGroupRowRefs.current.set( + buildProjectGroupCollapseKey(project.id, group.id), + element, + ); + } else { + threadGroupRowRefs.current.delete( + buildProjectGroupCollapseKey(project.id, group.id), + ); + } + }} + className="group/thread-group relative mb-1 rounded-lg" + data-thread-group-drop-surface="true" + data-project-id={project.id} + data-group-id={group.id} + data-last-group-id={lastGroupId ?? ""} + > +
    +
    { + if (suppressGroupClickRef.current === groupInteractionKey) { + suppressGroupClickRef.current = null; + return; + } + if (isAnySidebarDragged) return; + setGroupOpen(project.id, group.id, !isGroupOpen); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if (isAnySidebarDragged) return; + setGroupOpen(project.id, group.id, !isGroupOpen); + }} + onPointerDown={(event) => { + if (!canDragGroup || event.button !== 0 || draggedProjectId !== null) return; + if ( + shouldIgnoreSidebarDragPointerDown({ + currentTarget: event.currentTarget, + target: event.target, + }) + ) { + return; + } + event.currentTarget.setPointerCapture(event.pointerId); + pendingGroupDragRef.current = { + projectId: project.id, + groupId: group.id, + startX: event.clientX, + startY: event.clientY, + pointerId: event.pointerId, + element: event.currentTarget, + }; + }} + onContextMenu={(event) => { + event.preventDefault(); + void handleGroupContextMenu( + { + projectId: project.id, + projectCwd: project.cwd, + groupId: group.id, + groupLabel: group.label, + branch: group.branch, + worktreePath: group.worktreePath, + prUrl: groupPrStatus?.url ?? null, + entries: groupEntries, + }, + { + x: event.clientX, + y: event.clientY, + }, + ); + }} + > + + {groupPrStatus ? ( + + { + openPrLink(event, groupPrStatus.url); + }} + > + + + } + /> + {groupPrStatus.tooltip} + + ) : group.id === MAIN_THREAD_GROUP_ID ? ( + + ) : ( + + )} + + {group.label} + + + {groupEntries.length} + +
    + + { + event.preventDefault(); + event.stopPropagation(); + setProjectExpanded(project.id, true); + setCollapsedGroupIds((prev) => { + const collapseKey = buildProjectGroupCollapseKey( + project.id, + group.id, + ); + const uncollapsed = new Set(prev); + uncollapsed.delete(collapseKey); + return uncollapsed; + }); + void handleNewThread(project.id, { + branch: group.branch, + worktreePath: group.worktreePath, + envMode: + group.id === MAIN_THREAD_GROUP_ID ? "local" : "worktree", + }); + }} /> } - showOnHover - className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - void handleNewThread(project.id); - }} > - - } - /> - - {newThreadShortcutLabel - ? `New thread (${newThreadShortcutLabel})` - : "New thread"} - - -
    - - - - {visibleThreads.map((thread) => { - const isActive = routeThreadId === thread.id; - const threadStatus = resolveThreadStatusPill({ - thread, - hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true, - hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true, - }); - const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id) - .runningTerminalIds, - ); - - return ( - + + Compose in {group.label} + + +
    +
    +
    + {groupEntries.map((entry) => { + const thread = entry.thread; + const isActive = routeThreadId === entry.id; + const threadStatus = + thread !== null + ? resolveThreadStatusPill({ + thread, + hasPendingApprovals: + pendingApprovalByThreadId.get(thread.id) === true, + hasPendingUserInput: + pendingUserInputByThreadId.get(thread.id) === true, + }) + : null; + const terminalStatus = + thread !== null + ? terminalStatusFromRunningIds( + selectThreadTerminalState(terminalStateByThreadId, thread.id) + .runningTerminalIds, + ) + : null; + + return ( + } size="sm" isActive={isActive} - className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left hover:bg-accent hover:text-foreground ${ - isActive - ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50" - : "text-muted-foreground" - }`} - onClick={() => { - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onContextMenu={(event) => { - event.preventDefault(); - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > -
    - {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - {prStatus.tooltip} - - )} - {threadStatus && ( + className={buildThreadRowClassName({ + isActive, + isAnyGroupDragged, + })} + onClick={() => { + void navigate({ + to: "/$threadId", + params: { threadId: entry.id }, + }); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + void navigate({ + to: "/$threadId", + params: { threadId: entry.id }, + }); + }} + onContextMenu={(event) => { + if (thread === null) return; + event.preventDefault(); + void handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + }} + > +
    + {threadStatus && ( + - - {threadStatus.label} - - )} - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(thread.id, renamingTitle, thread.title); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename(thread.id, renamingTitle, thread.title); - } - }} - onClick={(e) => e.stopPropagation()} + className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${ + threadStatus.pulse ? "animate-pulse" : "" + }`} /> - ) : ( - - {thread.title} - - )} -
    -
    - {terminalStatus && ( - - - - )} + {threadStatus.label} + + )} + {thread !== null && renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(thread.id, renamingTitle, thread.title); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename(thread.id, renamingTitle, thread.title); + } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( - {formatRelativeTime(thread.createdAt)} + {entry.title} -
    - - - ); - })} - - {hasHiddenThreads && !isThreadListExpanded && ( - - } - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - expandThreadListForProject(project.id); - }} - > - Show more - - - )} - {hasHiddenThreads && isThreadListExpanded && ( - - } - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - collapseThreadListForProject(project.id); - }} - > - Show less + )} +
    +
    + {terminalStatus && ( + + + + )} + + {formatRelativeTime( + getSidebarThreadSortTimestamp( + { + id: entry.id, + createdAt: entry.createdAt, + thread: entry.thread, + }, + appSettings.sidebarThreadOrder, + ), + )} + +
    - )} - - - - )} - - ); - })} - - - - - {projects.length === 0 && !shouldShowProjectPathEntry && ( + ); + })} +
    +
    +
    +
    + ); + })} + {draggedGroup?.projectId === project.id ? ( + + ) : null} +
    +
    +
    +
    +
    + ); + })} + {draggedProjectId !== null ? ( + + ) : null} + + + {projects.length === 0 && !addingProject && (
    No projects yet
    diff --git a/apps/web/src/components/sidebarGroupInteractions.test.ts b/apps/web/src/components/sidebarGroupInteractions.test.ts new file mode 100644 index 000000000..4be6a47fb --- /dev/null +++ b/apps/web/src/components/sidebarGroupInteractions.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "vitest"; + +import { + buildThreadGroupCollapsibleKey, + buildThreadGroupChildrenClassName, + buildThreadGroupDragCursorClassName, + buildProjectGroupCollapseKey, + buildProjectChildrenClassName, + buildThreadGroupDropIndicatorClassName, + buildThreadGroupChevronClassName, + buildThreadGroupComposeButtonClassName, + buildThreadGroupHeaderClassName, + buildThreadRowClassName, + buildSidebarInteractionClassName, + expandCollapsedThreadGroupIds, + hasCrossedThreadGroupDragThreshold, + isProjectGroupOpen, + resolveThreadGroupDropEffect, + shouldIgnoreSidebarDragPointerDown, + shouldSnapThreadGroupDropToEnd, + isValidThreadGroupDropTarget, + setProjectGroupCollapsed, + shouldRenderProjectComposeButton, +} from "./sidebarGroupInteractions"; + +describe("sidebarGroupInteractions", () => { + it("removes the project-level compose button", () => { + expect(shouldRenderProjectComposeButton()).toBe(false); + }); + + it("keeps draggable group rows non-selectable and suppresses hover while dragging", () => { + const activeDragClassName = buildThreadGroupHeaderClassName({ + canDragGroup: true, + isDraggedGroup: true, + isAnyGroupDragged: true, + }); + + expect(activeDragClassName).toContain("select-none"); + expect(activeDragClassName).toContain("bg-accent/50"); + expect(activeDragClassName).not.toContain("hover:bg-accent/40"); + expect(activeDragClassName).toContain("cursor-grabbing"); + }); + + it("suppresses thread-row hover highlighting while any group drag is active", () => { + expect( + buildThreadRowClassName({ + isActive: false, + isAnyGroupDragged: true, + }), + ).toContain("pointer-events-none"); + expect( + buildThreadGroupComposeButtonClassName({ + isAnyGroupDragged: true, + }), + ).toContain("pointer-events-none"); + }); + + it("keeps draggable rows pointer-like before drag starts", () => { + expect( + buildThreadGroupHeaderClassName({ + canDragGroup: true, + isDraggedGroup: false, + isAnyGroupDragged: false, + }), + ).toContain("cursor-pointer"); + }); + + it("disables child hit targets while a group drag is active", () => { + expect(buildThreadGroupChildrenClassName({ isOpen: true, isAnyGroupDragged: true })).toContain( + "pointer-events-none", + ); + expect( + buildThreadGroupChildrenClassName({ isOpen: true, isAnyGroupDragged: false }), + ).toContain("grid-rows-[1fr]"); + expect( + buildThreadGroupChildrenClassName({ isOpen: false, isAnyGroupDragged: false }), + ).toContain("grid-rows-[0fr]"); + }); + + it("keeps project bodies mounted and driven by explicit project open state", () => { + expect( + buildProjectChildrenClassName({ isOpen: true, isAnyProjectDragged: false }), + ).toContain("grid-rows-[1fr]"); + expect( + buildProjectChildrenClassName({ isOpen: false, isAnyProjectDragged: false }), + ).toContain("grid-rows-[0fr]"); + expect( + buildProjectChildrenClassName({ isOpen: true, isAnyProjectDragged: true }), + ).toContain("pointer-events-none"); + }); + + it("shows a leading chevron that rotates with the group open state", () => { + expect(buildThreadGroupChevronClassName({ isOpen: false })).not.toContain("rotate-90"); + expect(buildThreadGroupChevronClassName({ isOpen: true })).toContain("rotate-90"); + }); + + it("disables text selection across the sidebar only while a group drag is active", () => { + expect(buildSidebarInteractionClassName({ isAnyGroupDragged: true })).toContain("select-none"); + expect(buildSidebarInteractionClassName({ isAnyGroupDragged: false })).toBe(""); + }); + + it("expands a target group without mutating the existing collapsed set", () => { + const collapsed = new Set(["main", "worktree:/tmp/project/.t3/worktrees/feature-a"]); + const next = expandCollapsedThreadGroupIds(collapsed, "worktree:/tmp/project/.t3/worktrees/feature-a"); + + expect(collapsed.has("worktree:/tmp/project/.t3/worktrees/feature-a")).toBe(true); + expect(next.has("worktree:/tmp/project/.t3/worktrees/feature-a")).toBe(false); + expect(next.has("main")).toBe(true); + }); + + it("scopes collapse state by project and group id", () => { + expect(buildProjectGroupCollapseKey("project-a", "main")).not.toBe( + buildProjectGroupCollapseKey("project-b", "main"), + ); + }); + + it("sets project group collapse state explicitly instead of toggling blindly", () => { + const collapseKey = buildProjectGroupCollapseKey("project-a", "main"); + const collapsed = setProjectGroupCollapsed(new Set(), collapseKey, false); + const expanded = setProjectGroupCollapsed(collapsed, collapseKey, true); + + expect(collapsed.has(collapseKey)).toBe(true); + expect(expanded.has(collapseKey)).toBe(false); + }); + + it("derives group visibility from the scoped project collapse key", () => { + const collapsed = new Set([ + buildProjectGroupCollapseKey("project-a", "worktree:a"), + buildProjectGroupCollapseKey("project-b", "main"), + ]); + + expect(isProjectGroupOpen(collapsed, "project-a", "worktree:a")).toBe(false); + expect(isProjectGroupOpen(collapsed, "project-b", "worktree:a")).toBe(true); + expect(isProjectGroupOpen(collapsed, "project-a", "main")).toBe(true); + }); + + it("shows a visible drop indicator only for the active drop target", () => { + expect(buildThreadGroupDropIndicatorClassName({ isActiveDropTarget: true })).toContain( + "bg-primary", + ); + expect(buildThreadGroupDropIndicatorClassName({ isActiveDropTarget: false })).toContain( + "opacity-0", + ); + }); + + it("invalidates self-drop targets including dropping to end when already last", () => { + expect( + isValidThreadGroupDropTarget({ + draggedGroupId: "worktree:a", + targetGroupId: "worktree:a", + lastGroupId: "worktree:b", + }), + ).toBe(false); + expect( + isValidThreadGroupDropTarget({ + draggedGroupId: "worktree:b", + targetGroupId: null, + lastGroupId: "worktree:b", + }), + ).toBe(false); + expect( + isValidThreadGroupDropTarget({ + draggedGroupId: "worktree:a", + targetGroupId: "worktree:b", + lastGroupId: "worktree:b", + }), + ).toBe(true); + }); + + it("uses the same move-vs-none decision for drag feedback and drop handling", () => { + expect( + resolveThreadGroupDropEffect({ + draggedProjectId: "project-a", + targetProjectId: "project-a", + draggedGroupId: "worktree:a", + targetGroupId: "worktree:b", + lastGroupId: "worktree:b", + }), + ).toBe("move"); + + expect( + resolveThreadGroupDropEffect({ + draggedProjectId: "project-a", + targetProjectId: "project-b", + draggedGroupId: "worktree:a", + targetGroupId: "worktree:b", + lastGroupId: "worktree:b", + }), + ).toBe("none"); + + expect( + resolveThreadGroupDropEffect({ + draggedProjectId: "project-a", + targetProjectId: "project-a", + draggedGroupId: "worktree:b", + targetGroupId: null, + lastGroupId: "worktree:b", + }), + ).toBe("none"); + }); + + it("keeps end-of-list dropping active slightly below the visible project list", () => { + expect( + shouldSnapThreadGroupDropToEnd({ + pointerX: 120, + pointerY: 248, + left: 20, + right: 220, + bottom: 200, + snapStartY: 184, + thresholdPx: 64, + }), + ).toBe(true); + + expect( + shouldSnapThreadGroupDropToEnd({ + pointerX: 120, + pointerY: 300, + left: 20, + right: 220, + bottom: 200, + snapStartY: 184, + thresholdPx: 64, + }), + ).toBe(false); + + expect( + shouldSnapThreadGroupDropToEnd({ + pointerX: 10, + pointerY: 220, + left: 20, + right: 220, + bottom: 200, + snapStartY: 184, + thresholdPx: 64, + }), + ).toBe(false); + + expect( + shouldSnapThreadGroupDropToEnd({ + pointerX: 120, + pointerY: 120, + left: 20, + right: 220, + bottom: 200, + snapStartY: 184, + thresholdPx: 64, + }), + ).toBe(false); + }); + + it("uses a position-scoped collapsible key so reordered open groups remount cleanly", () => { + expect( + buildThreadGroupCollapsibleKey("project-a", "worktree:a", [ + "main", + "worktree:a", + "worktree:b", + ]), + ).not.toBe( + buildThreadGroupCollapsibleKey("project-a", "worktree:a", [ + "main", + "worktree:b", + "worktree:a", + ]), + ); + }); + + it("forces a consistent grabbing cursor while dragging", () => { + expect(buildThreadGroupDragCursorClassName({ isDragging: true })).toContain("cursor-grabbing"); + expect(buildThreadGroupDragCursorClassName({ isDragging: false })).toBe(""); + }); + + it("activates dragging only after the pointer crosses the drag threshold", () => { + expect( + hasCrossedThreadGroupDragThreshold({ + startX: 100, + startY: 100, + currentX: 103, + currentY: 104, + thresholdPx: 4, + }), + ).toBe(false); + + expect( + hasCrossedThreadGroupDragThreshold({ + startX: 100, + startY: 100, + currentX: 105, + currentY: 100, + thresholdPx: 4, + }), + ).toBe(true); + + expect( + hasCrossedThreadGroupDragThreshold({ + startX: 100, + startY: 100, + currentX: 100, + currentY: 106, + thresholdPx: 4, + }), + ).toBe(true); + }); + + it("allows a draggable button surface to start drag while still ignoring nested interactive children", () => { + const currentTarget = { + closest: () => currentTarget, + } as unknown as EventTarget; + const nestedIconButton = { + closest: () => currentTarget, + } as unknown as EventTarget; + const nestedInnerTarget = { + closest: () => nestedIconButton, + } as unknown as EventTarget; + + expect( + shouldIgnoreSidebarDragPointerDown({ + currentTarget, + target: currentTarget, + }), + ).toBe(false); + + expect( + shouldIgnoreSidebarDragPointerDown({ + currentTarget, + target: nestedInnerTarget, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/sidebarGroupInteractions.ts b/apps/web/src/components/sidebarGroupInteractions.ts new file mode 100644 index 000000000..5f0daf520 --- /dev/null +++ b/apps/web/src/components/sidebarGroupInteractions.ts @@ -0,0 +1,236 @@ +export function shouldRenderProjectComposeButton(): boolean { + return false; +} + +export function buildProjectGroupCollapseKey(projectId: string, groupId: string): string { + return `${projectId}\u0000${groupId}`; +} + +export function buildThreadGroupCollapsibleKey( + projectId: string, + groupId: string, + orderedGroupIds: ReadonlyArray, +): string { + return `${projectId}\u0000${groupId}\u0000${orderedGroupIds.join("\u0001")}`; +} + +export function buildSidebarInteractionClassName(input: { + isAnyGroupDragged: boolean; +}): string { + return input.isAnyGroupDragged ? "select-none" : ""; +} + +export function setProjectGroupCollapsed( + collapsedGroupIds: ReadonlySet, + collapseKey: string, + open: boolean, +): Set { + const next = new Set(collapsedGroupIds); + if (open) { + next.delete(collapseKey); + } else { + next.add(collapseKey); + } + return next; +} + +export function isProjectGroupOpen( + collapsedGroupIds: ReadonlySet, + projectId: string, + groupId: string, +): boolean { + return !collapsedGroupIds.has(buildProjectGroupCollapseKey(projectId, groupId)); +} + +export function expandCollapsedThreadGroupIds( + collapsedGroupIds: ReadonlySet, + groupId: string, +): Set { + const next = new Set(collapsedGroupIds); + next.delete(groupId); + return next; +} + +export function buildThreadGroupHeaderClassName(input: { + canDragGroup: boolean; + isDraggedGroup: boolean; + isAnyGroupDragged: boolean; +}): string { + return [ + "group/thread-group flex items-center gap-1.5 rounded-md px-2 py-1 pr-7", + input.isDraggedGroup ? "bg-accent/50" : "", + !input.isDraggedGroup && !input.isAnyGroupDragged ? "hover:bg-accent/40" : "", + input.canDragGroup + ? input.isAnyGroupDragged + ? "cursor-grabbing select-none" + : "cursor-pointer select-none" + : "cursor-pointer", + ] + .filter(Boolean) + .join(" "); +} + +export function buildThreadGroupDropIndicatorClassName(input: { + isActiveDropTarget: boolean; +}): string { + return [ + "pointer-events-none absolute inset-x-2 -top-1 h-0.5 rounded-full transition-opacity", + input.isActiveDropTarget ? "bg-primary opacity-100" : "bg-transparent opacity-0", + ] + .filter(Boolean) + .join(" "); +} + +export function hasCrossedThreadGroupDragThreshold(input: { + startX: number; + startY: number; + currentX: number; + currentY: number; + thresholdPx: number; +}): boolean { + const deltaX = Math.abs(input.currentX - input.startX); + const deltaY = Math.abs(input.currentY - input.startY); + return deltaX > input.thresholdPx || deltaY > input.thresholdPx; +} + +export function shouldIgnoreSidebarDragPointerDown(input: { + currentTarget: EventTarget; + target: EventTarget | null; +}): boolean { + if ( + input.target === null || + typeof input.target !== "object" || + !("closest" in input.target) || + typeof input.target.closest !== "function" + ) { + return false; + } + + const interactiveAncestor = input.target.closest("button, a, input, textarea, select"); + return interactiveAncestor !== null && interactiveAncestor !== input.currentTarget; +} + +export function isValidThreadGroupDropTarget(input: { + draggedGroupId: string; + targetGroupId: string | null; + lastGroupId: string | null; +}): boolean { + if (input.targetGroupId === input.draggedGroupId) { + return false; + } + if (input.targetGroupId === null && input.draggedGroupId === input.lastGroupId) { + return false; + } + return true; +} + +export function resolveThreadGroupDropEffect(input: { + draggedProjectId: string | null; + targetProjectId: string; + draggedGroupId: string | null; + targetGroupId: string | null; + lastGroupId: string | null; +}): "move" | "none" { + if ( + input.draggedProjectId === null || + input.draggedGroupId === null || + input.draggedProjectId !== input.targetProjectId + ) { + return "none"; + } + + return isValidThreadGroupDropTarget({ + draggedGroupId: input.draggedGroupId, + targetGroupId: input.targetGroupId, + lastGroupId: input.lastGroupId, + }) + ? "move" + : "none"; +} + +export function shouldSnapThreadGroupDropToEnd(input: { + pointerX: number; + pointerY: number; + left: number; + right: number; + bottom: number; + snapStartY: number; + thresholdPx: number; +}): boolean { + const withinHorizontalBounds = input.pointerX >= input.left && input.pointerX <= input.right; + const withinVerticalBounds = + input.pointerY >= input.snapStartY && input.pointerY <= input.bottom + input.thresholdPx; + + return withinHorizontalBounds && withinVerticalBounds; +} + +export function buildThreadGroupDragCursorClassName(input: { + isDragging: boolean; +}): string { + return input.isDragging ? "cursor-grabbing" : ""; +} + +export function buildThreadGroupChevronClassName(input: { + isOpen: boolean; +}): string { + return [ + "size-3 shrink-0 text-muted-foreground/50 transition-transform duration-150", + input.isOpen ? "rotate-90" : "", + ] + .filter(Boolean) + .join(" "); +} + +export function buildThreadGroupComposeButtonClassName(input: { + isAnyGroupDragged: boolean; +}): string { + return [ + "inline-flex size-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/65 opacity-0 transition-colors", + input.isAnyGroupDragged + ? "pointer-events-none" + : "group-hover/thread-group:opacity-100 hover:bg-secondary hover:text-foreground", + ] + .filter(Boolean) + .join(" "); +} + +export function buildThreadRowClassName(input: { + isActive: boolean; + isAnyGroupDragged: boolean; +}): string { + return [ + "h-7 w-full translate-x-0 cursor-default justify-start px-2 pl-3 text-left", + input.isAnyGroupDragged ? "pointer-events-none" : "hover:bg-accent hover:text-foreground", + input.isActive + ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50" + : "text-muted-foreground", + ] + .filter(Boolean) + .join(" "); +} + +export function buildThreadGroupChildrenClassName(input: { + isOpen: boolean; + isAnyGroupDragged: boolean; +}): string { + return [ + "grid transition-[grid-template-rows,opacity] duration-200 ease-out", + input.isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0", + input.isAnyGroupDragged ? "pointer-events-none" : "", + ] + .filter(Boolean) + .join(" "); +} + +export function buildProjectChildrenClassName(input: { + isOpen: boolean; + isAnyProjectDragged: boolean; +}): string { + return [ + "grid transition-[grid-template-rows,opacity] duration-200 ease-out", + input.isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0", + input.isAnyProjectDragged ? "pointer-events-none" : "", + ] + .filter(Boolean) + .join(" "); +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 2bcd9cbbc..edd0b4358 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,11 +1,8 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - type ComposerImageAttachment, - createDebouncedStorage, - useComposerDraftStore, -} from "./composerDraftStore"; +import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; +import { MAIN_THREAD_GROUP_ID } from "./threadGroups"; function makeImage(input: { id: string; @@ -456,124 +453,79 @@ describe("composerDraftStore runtime and interaction settings", () => { }); }); -// --------------------------------------------------------------------------- -// createDebouncedStorage -// --------------------------------------------------------------------------- +describe("composerDraftStore project group draft thread mapping", () => { + const projectId = ProjectId.makeUnsafe("project-group"); + const mainThreadId = ThreadId.makeUnsafe("thread-main-group"); + const featureThreadId = ThreadId.makeUnsafe("thread-feature-group"); -function createMockStorage() { - const store = new Map(); - return { - getItem: vi.fn((name: string) => store.get(name) ?? null), - setItem: vi.fn((name: string, value: string) => { - store.set(name, value); - }), - removeItem: vi.fn((name: string) => { - store.delete(name); - }), - }; -} - -describe("createDebouncedStorage", () => { beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("delegates getItem immediately", () => { - const base = createMockStorage(); - base.getItem.mockReturnValueOnce("value"); - const storage = createDebouncedStorage(base); - - expect(storage.getItem("key")).toBe("value"); - expect(base.getItem).toHaveBeenCalledWith("key"); - }); - - it("does not write to base storage until the debounce fires", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); - - storage.setItem("key", "v1"); - expect(base.setItem).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(299); - expect(base.setItem).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(1); - expect(base.setItem).toHaveBeenCalledWith("key", "v1"); - }); - - it("only writes the last value when setItem is called rapidly", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); - - storage.setItem("key", "v1"); - storage.setItem("key", "v2"); - storage.setItem("key", "v3"); - - vi.advanceTimersByTime(300); - expect(base.setItem).toHaveBeenCalledTimes(1); - expect(base.setItem).toHaveBeenCalledWith("key", "v3"); - }); - - it("removeItem cancels a pending setItem write", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); - - storage.setItem("key", "v1"); - storage.removeItem("key"); - - vi.advanceTimersByTime(300); - expect(base.setItem).not.toHaveBeenCalled(); - expect(base.removeItem).toHaveBeenCalledWith("key"); + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + projectGroupDraftThreadIdById: {}, + }); }); - it("flush writes the pending value immediately", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); - - storage.setItem("key", "v1"); - expect(base.setItem).not.toHaveBeenCalled(); - - storage.flush(); - expect(base.setItem).toHaveBeenCalledWith("key", "v1"); - - // Timer should be cancelled; no duplicate write. - vi.advanceTimersByTime(300); - expect(base.setItem).toHaveBeenCalledTimes(1); - }); + it("stores independent drafts for Main and a worktree subgroup in the same project", () => { + const store = useComposerDraftStore.getState(); - it("flush is a no-op when nothing is pending", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); + store.setProjectGroupDraftThreadId(projectId, MAIN_THREAD_GROUP_ID, mainThreadId, { + branch: null, + worktreePath: null, + envMode: "local", + }); + store.setProjectGroupDraftThreadId( + projectId, + "worktree:/tmp/project/.t3/worktrees/feature-a", + featureThreadId, + { + branch: "feature/a", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + envMode: "worktree", + }, + ); - storage.flush(); - expect(base.setItem).not.toHaveBeenCalled(); + expect( + useComposerDraftStore.getState().getDraftThreadByProjectGroupId(projectId, MAIN_THREAD_GROUP_ID), + ).toMatchObject({ + threadId: mainThreadId, + branch: null, + worktreePath: null, + envMode: "local", + }); + expect( + useComposerDraftStore + .getState() + .getDraftThreadByProjectGroupId( + projectId, + "worktree:/tmp/project/.t3/worktrees/feature-a", + ), + ).toMatchObject({ + threadId: featureThreadId, + branch: "feature/a", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + envMode: "worktree", + }); }); - it("flush after removeItem is a no-op", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); - - storage.setItem("key", "v1"); - storage.removeItem("key"); - storage.flush(); - - expect(base.setItem).not.toHaveBeenCalled(); - }); + it("clears only the targeted subgroup draft mapping", () => { + const store = useComposerDraftStore.getState(); - it("setItem works normally after removeItem cancels a pending write", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); + store.setProjectGroupDraftThreadId(projectId, MAIN_THREAD_GROUP_ID, mainThreadId); + store.setProjectGroupDraftThreadId(projectId, "branch:feature/a", featureThreadId, { + branch: "feature/a", + worktreePath: null, + envMode: "worktree", + }); - storage.setItem("key", "v1"); - storage.removeItem("key"); - storage.setItem("key", "v2"); + store.clearProjectGroupDraftThreadId(projectId, "branch:feature/a"); - vi.advanceTimersByTime(300); - expect(base.setItem).toHaveBeenCalledTimes(1); - expect(base.setItem).toHaveBeenCalledWith("key", "v2"); + expect( + useComposerDraftStore.getState().getDraftThreadByProjectGroupId(projectId, MAIN_THREAD_GROUP_ID), + ).toMatchObject({ threadId: mainThreadId }); + expect( + useComposerDraftStore.getState().getDraftThreadByProjectGroupId(projectId, "branch:feature/a"), + ).toBeNull(); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 0369b9773..552fbb35d 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -15,6 +15,7 @@ import { type ChatImageAttachment, } from "./types"; import { Debouncer } from "@tanstack/react-pacer"; +import { buildThreadGroupId, MAIN_THREAD_GROUP_ID } from "./threadGroups"; import { create } from "zustand"; import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; @@ -55,7 +56,6 @@ const composerDebouncedStorage: DebouncedStorage = ? createDebouncedStorage(localStorage) : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} }; -// Flush pending composer draft writes before page unload to prevent data loss. if (typeof window !== "undefined") { window.addEventListener("beforeunload", () => { composerDebouncedStorage.flush(); @@ -100,6 +100,7 @@ interface PersistedDraftThreadState { interface PersistedComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; + projectGroupDraftThreadIdById: Record; projectDraftThreadIdByProjectId: Record; } @@ -130,12 +131,35 @@ interface ProjectDraftThread extends DraftThreadState { threadId: ThreadId; } +interface ProjectGroupDraftThread extends DraftThreadState { + threadId: ThreadId; + groupId: string; +} + interface ComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; + projectGroupDraftThreadIdById: Record; projectDraftThreadIdByProjectId: Record; + getDraftThreadByProjectGroupId: ( + projectId: ProjectId, + groupId: string, + ) => ProjectGroupDraftThread | null; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; + setProjectGroupDraftThreadId: ( + projectId: ProjectId, + groupId: string, + threadId: ThreadId, + options?: { + branch?: string | null; + worktreePath?: string | null; + createdAt?: string; + envMode?: DraftThreadEnvMode; + runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; + }, + ) => void; setProjectDraftThreadId: ( projectId: ProjectId, threadId: ThreadId, @@ -160,6 +184,12 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; }, ) => void; + clearProjectGroupDraftThreadId: (projectId: ProjectId, groupId: string) => void; + clearProjectGroupDraftThreadById: ( + projectId: ProjectId, + groupId: string, + threadId: ThreadId, + ) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; @@ -188,6 +218,7 @@ interface ComposerDraftStoreState { const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { draftsByThreadId: {}, draftThreadsByThreadId: {}, + projectGroupDraftThreadIdById: {}, projectDraftThreadIdByProjectId: {}, }; @@ -311,6 +342,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer const candidate = value as Record; const rawDraftMap = candidate.draftsByThreadId; const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; + const rawProjectGroupDraftThreadIdById = candidate.projectGroupDraftThreadIdById; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; const draftThreadsByThreadId: PersistedComposerDraftStoreState["draftThreadsByThreadId"] = {}; if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { @@ -354,6 +386,22 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer }; } } + const projectGroupDraftThreadIdById: PersistedComposerDraftStoreState["projectGroupDraftThreadIdById"] = + {}; + if (rawProjectGroupDraftThreadIdById && typeof rawProjectGroupDraftThreadIdById === "object") { + for (const [mappingId, threadId] of Object.entries( + rawProjectGroupDraftThreadIdById as Record, + )) { + if ( + typeof mappingId === "string" && + mappingId.length > 0 && + typeof threadId === "string" && + threadId.length > 0 + ) { + projectGroupDraftThreadIdById[mappingId] = threadId as ThreadId; + } + } + } const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] = {}; if ( @@ -370,6 +418,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer threadId.length > 0 ) { projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId; + const mainGroupMappingId = `${projectId}\u0000${MAIN_THREAD_GROUP_ID}`; + if (!projectGroupDraftThreadIdById[mainGroupMappingId]) { + projectGroupDraftThreadIdById[mainGroupMappingId] = threadId as ThreadId; + } if (!draftThreadsByThreadId[threadId as ThreadId]) { draftThreadsByThreadId[threadId as ThreadId] = { projectId: projectId as ProjectId, @@ -390,7 +442,12 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer } } if (!rawDraftMap || typeof rawDraftMap !== "object") { - return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; + return { + draftsByThreadId: {}, + draftThreadsByThreadId, + projectGroupDraftThreadIdById, + projectDraftThreadIdByProjectId, + }; } const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record)) { @@ -457,10 +514,43 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, + projectGroupDraftThreadIdById, projectDraftThreadIdByProjectId, }; } +function toProjectGroupDraftMapId(projectId: ProjectId, groupId: string): string { + return `${projectId}\u0000${groupId}`; +} + +function toMainProjectDraftMap(projectGroupDraftThreadIdById: Record) { + const next: Record = {}; + for (const [mappingId, threadId] of Object.entries(projectGroupDraftThreadIdById)) { + const separatorIndex = mappingId.indexOf("\u0000"); + if (separatorIndex <= 0) continue; + const projectId = mappingId.slice(0, separatorIndex) as ProjectId; + const groupId = mappingId.slice(separatorIndex + 1); + if (groupId === MAIN_THREAD_GROUP_ID) { + next[projectId] = threadId as ThreadId; + } + } + return next; +} + +function isThreadMappedByAnyProjectGroup( + projectGroupDraftThreadIdById: Record, + threadId: ThreadId, +): boolean { + return Object.values(projectGroupDraftThreadIdById).includes(threadId); +} + +function toDraftThreadGroupId(draftThread: DraftThreadState): string { + return buildThreadGroupId({ + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + }); +} + function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraftStoreState { if (!raw) { return EMPTY_PERSISTED_DRAFT_STORE_STATE; @@ -567,12 +657,13 @@ export const useComposerDraftStore = create()( (set, get) => ({ draftsByThreadId: {}, draftThreadsByThreadId: {}, + projectGroupDraftThreadIdById: {}, projectDraftThreadIdByProjectId: {}, - getDraftThreadByProjectId: (projectId) => { - if (projectId.length === 0) { + getDraftThreadByProjectGroupId: (projectId, groupId) => { + if (projectId.length === 0 || groupId.length === 0) { return null; } - const threadId = get().projectDraftThreadIdByProjectId[projectId]; + const threadId = get().projectGroupDraftThreadIdById[toProjectGroupDraftMapId(projectId, groupId)]; if (!threadId) { return null; } @@ -582,22 +673,44 @@ export const useComposerDraftStore = create()( } return { threadId, + groupId, ...draftThread, }; }, + getDraftThreadByProjectId: (projectId) => { + let groupDraft = get().getDraftThreadByProjectGroupId(projectId, MAIN_THREAD_GROUP_ID); + if (!groupDraft) { + const projectPrefix = `${projectId}\u0000`; + const fallbackEntry = Object.entries(get().projectGroupDraftThreadIdById).find( + ([mappingId]) => mappingId.startsWith(projectPrefix), + ); + if (fallbackEntry) { + groupDraft = get().getDraftThreadByProjectGroupId( + projectId, + fallbackEntry[0].slice(projectPrefix.length), + ); + } + } + if (!groupDraft) { + return null; + } + const { groupId: _groupId, ...projectDraft } = groupDraft; + return projectDraft; + }, getDraftThread: (threadId) => { if (threadId.length === 0) { return null; } return get().draftThreadsByThreadId[threadId] ?? null; }, - setProjectDraftThreadId: (projectId, threadId, options) => { - if (projectId.length === 0 || threadId.length === 0) { + setProjectGroupDraftThreadId: (projectId, groupId, threadId, options) => { + if (projectId.length === 0 || groupId.length === 0 || threadId.length === 0) { return; } set((state) => { const existingThread = state.draftThreadsByThreadId[threadId]; - const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId]; + const mappingId = toProjectGroupDraftMapId(projectId, groupId); + const previousThreadIdForGroup = state.projectGroupDraftThreadIdById[mappingId]; const nextWorktreePath = options?.worktreePath === undefined ? (existingThread?.worktreePath ?? null) @@ -619,7 +732,7 @@ export const useComposerDraftStore = create()( options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), }; - const hasSameProjectMapping = previousThreadIdForProject === threadId; + const hasSameGroupMapping = previousThreadIdForGroup === threadId; const hasSameDraftThread = existingThread && existingThread.projectId === nextDraftThread.projectId && @@ -629,12 +742,12 @@ export const useComposerDraftStore = create()( existingThread.branch === nextDraftThread.branch && existingThread.worktreePath === nextDraftThread.worktreePath && existingThread.envMode === nextDraftThread.envMode; - if (hasSameProjectMapping && hasSameDraftThread) { + if (hasSameGroupMapping && hasSameDraftThread) { return state; } - const nextProjectDraftThreadIdByProjectId: Record = { - ...state.projectDraftThreadIdByProjectId, - [projectId]: threadId, + const nextProjectGroupDraftThreadIdById = { + ...state.projectGroupDraftThreadIdById, + [mappingId]: threadId, }; const nextDraftThreadsByThreadId: Record = { ...state.draftThreadsByThreadId, @@ -642,23 +755,27 @@ export const useComposerDraftStore = create()( }; let nextDraftsByThreadId = state.draftsByThreadId; if ( - previousThreadIdForProject && - previousThreadIdForProject !== threadId && - !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject) + previousThreadIdForGroup && + previousThreadIdForGroup !== threadId && + !isThreadMappedByAnyProjectGroup(nextProjectGroupDraftThreadIdById, previousThreadIdForGroup) ) { - delete nextDraftThreadsByThreadId[previousThreadIdForProject]; - if (state.draftsByThreadId[previousThreadIdForProject] !== undefined) { + delete nextDraftThreadsByThreadId[previousThreadIdForGroup]; + if (state.draftsByThreadId[previousThreadIdForGroup] !== undefined) { nextDraftsByThreadId = { ...state.draftsByThreadId }; - delete nextDraftsByThreadId[previousThreadIdForProject]; + delete nextDraftsByThreadId[previousThreadIdForGroup]; } } return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, + projectGroupDraftThreadIdById: nextProjectGroupDraftThreadIdById, + projectDraftThreadIdByProjectId: toMainProjectDraftMap(nextProjectGroupDraftThreadIdById), }; }); }, + setProjectDraftThreadId: (projectId, threadId, options) => { + get().setProjectGroupDraftThreadId(projectId, MAIN_THREAD_GROUP_ID, threadId, options); + }, setDraftThreadContext: (threadId, options) => { if (threadId.length === 0) { return; @@ -699,41 +816,41 @@ export const useComposerDraftStore = create()( if (isUnchanged) { return state; } - const nextProjectDraftThreadIdByProjectId: Record = { - ...state.projectDraftThreadIdByProjectId, - [nextProjectId]: threadId, - }; - if (existing.projectId !== nextProjectId) { - if (nextProjectDraftThreadIdByProjectId[existing.projectId] === threadId) { - delete nextProjectDraftThreadIdByProjectId[existing.projectId]; + const nextProjectGroupDraftThreadIdById = { ...state.projectGroupDraftThreadIdById }; + for (const [mappingId, draftThreadId] of Object.entries(nextProjectGroupDraftThreadIdById)) { + if (draftThreadId === threadId) { + delete nextProjectGroupDraftThreadIdById[mappingId]; } } + nextProjectGroupDraftThreadIdById[ + toProjectGroupDraftMapId(nextProjectId, toDraftThreadGroupId(nextDraftThread)) + ] = threadId; return { draftThreadsByThreadId: { ...state.draftThreadsByThreadId, [threadId]: nextDraftThread, }, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, + projectGroupDraftThreadIdById: nextProjectGroupDraftThreadIdById, + projectDraftThreadIdByProjectId: toMainProjectDraftMap(nextProjectGroupDraftThreadIdById), }; }); }, - clearProjectDraftThreadId: (projectId) => { - if (projectId.length === 0) { + clearProjectGroupDraftThreadId: (projectId, groupId) => { + if (projectId.length === 0 || groupId.length === 0) { return; } set((state) => { - const threadId = state.projectDraftThreadIdByProjectId[projectId]; + const mappingId = toProjectGroupDraftMapId(projectId, groupId); + const threadId = state.projectGroupDraftThreadIdById[mappingId]; if (threadId === undefined) { return state; } - const { [projectId]: _removed, ...restProjectMappingsRaw } = - state.projectDraftThreadIdByProjectId; - const restProjectMappings = restProjectMappingsRaw as Record; + const { [mappingId]: _removed, ...restGroupMappings } = state.projectGroupDraftThreadIdById; const nextDraftThreadsByThreadId: Record = { ...state.draftThreadsByThreadId, }; let nextDraftsByThreadId = state.draftsByThreadId; - if (!Object.values(restProjectMappings).includes(threadId)) { + if (!isThreadMappedByAnyProjectGroup(restGroupMappings, threadId)) { delete nextDraftThreadsByThreadId[threadId]; if (state.draftsByThreadId[threadId] !== undefined) { nextDraftsByThreadId = { ...state.draftsByThreadId }; @@ -743,38 +860,46 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: restProjectMappings, + projectGroupDraftThreadIdById: restGroupMappings, + projectDraftThreadIdByProjectId: toMainProjectDraftMap(restGroupMappings), }; }); }, + clearProjectGroupDraftThreadById: (projectId, groupId, threadId) => { + if (projectId.length === 0 || groupId.length === 0 || threadId.length === 0) { + return; + } + const mappingId = toProjectGroupDraftMapId(projectId, groupId); + if (get().projectGroupDraftThreadIdById[mappingId] !== threadId) { + return; + } + get().clearProjectGroupDraftThreadId(projectId, groupId); + }, + clearProjectDraftThreadId: (projectId) => { + const mainMappingId = toProjectGroupDraftMapId(projectId, MAIN_THREAD_GROUP_ID); + if (get().projectGroupDraftThreadIdById[mainMappingId]) { + get().clearProjectGroupDraftThreadId(projectId, MAIN_THREAD_GROUP_ID); + return; + } + const projectPrefix = `${projectId}\u0000`; + const fallbackGroupId = Object.keys(get().projectGroupDraftThreadIdById).find((mappingId) => + mappingId.startsWith(projectPrefix), + ); + if (fallbackGroupId) { + get().clearProjectGroupDraftThreadId(projectId, fallbackGroupId.slice(projectPrefix.length)); + } + }, clearProjectDraftThreadById: (projectId, threadId) => { if (projectId.length === 0 || threadId.length === 0) { return; } - set((state) => { - if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) { - return state; + const projectPrefix = `${projectId}\u0000`; + for (const [mappingId, draftThreadId] of Object.entries(get().projectGroupDraftThreadIdById)) { + if (mappingId.startsWith(projectPrefix) && draftThreadId === threadId) { + get().clearProjectGroupDraftThreadId(projectId, mappingId.slice(projectPrefix.length)); + return; } - const { [projectId]: _removed, ...restProjectMappingsRaw } = - state.projectDraftThreadIdByProjectId; - const restProjectMappings = restProjectMappingsRaw as Record; - const nextDraftThreadsByThreadId: Record = { - ...state.draftThreadsByThreadId, - }; - let nextDraftsByThreadId = state.draftsByThreadId; - if (!Object.values(restProjectMappings).includes(threadId)) { - delete nextDraftThreadsByThreadId[threadId]; - if (state.draftsByThreadId[threadId] !== undefined) { - nextDraftsByThreadId = { ...state.draftsByThreadId }; - delete nextDraftsByThreadId[threadId]; - } - } - return { - draftsByThreadId: nextDraftsByThreadId, - draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: restProjectMappings, - }; - }); + } }, clearDraftThread: (threadId) => { if (threadId.length === 0) { @@ -782,22 +907,24 @@ export const useComposerDraftStore = create()( } set((state) => { const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; - const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( + const hasProjectMapping = isThreadMappedByAnyProjectGroup( + state.projectGroupDraftThreadIdById, threadId, ); if (!hasDraftThread && !hasProjectMapping) { return state; } - const nextProjectDraftThreadIdByProjectId = Object.fromEntries( - Object.entries(state.projectDraftThreadIdByProjectId).filter( + const nextProjectGroupDraftThreadIdById = Object.fromEntries( + Object.entries(state.projectGroupDraftThreadIdById).filter( ([, draftThreadId]) => draftThreadId !== threadId, ), - ) as Record; + ) as Record; const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = state.draftThreadsByThreadId; return { draftThreadsByThreadId: restDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, + projectGroupDraftThreadIdById: nextProjectGroupDraftThreadIdById, + projectDraftThreadIdByProjectId: toMainProjectDraftMap(nextProjectGroupDraftThreadIdById), }; }); }, @@ -1185,7 +1312,8 @@ export const useComposerDraftStore = create()( set((state) => { const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; - const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( + const hasProjectMapping = isThreadMappedByAnyProjectGroup( + state.projectGroupDraftThreadIdById, threadId, ); if (!hasComposerDraft && !hasDraftThread && !hasProjectMapping) { @@ -1195,15 +1323,16 @@ export const useComposerDraftStore = create()( state.draftsByThreadId; const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = state.draftThreadsByThreadId; - const nextProjectDraftThreadIdByProjectId = Object.fromEntries( - Object.entries(state.projectDraftThreadIdByProjectId).filter( + const nextProjectGroupDraftThreadIdById = Object.fromEntries( + Object.entries(state.projectGroupDraftThreadIdById).filter( ([, draftThreadId]) => draftThreadId !== threadId, ), - ) as Record; + ) as Record; return { draftsByThreadId: restComposerDraftsByThreadId, draftThreadsByThreadId: restDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, + projectGroupDraftThreadIdById: nextProjectGroupDraftThreadIdById, + projectDraftThreadIdByProjectId: toMainProjectDraftMap(nextProjectGroupDraftThreadIdById), }; }); }, @@ -1257,6 +1386,7 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: persistedDraftsByThreadId, draftThreadsByThreadId: state.draftThreadsByThreadId, + projectGroupDraftThreadIdById: state.projectGroupDraftThreadIdById, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, }; }, @@ -1272,6 +1402,7 @@ export const useComposerDraftStore = create()( ...currentState, draftsByThreadId, draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, + projectGroupDraftThreadIdById: normalizedPersisted.projectGroupDraftThreadIdById, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, }; }, diff --git a/apps/web/src/projectOrder.test.ts b/apps/web/src/projectOrder.test.ts new file mode 100644 index 000000000..8ffe67997 --- /dev/null +++ b/apps/web/src/projectOrder.test.ts @@ -0,0 +1,168 @@ +import { ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + orderProjectsByIds, + projectOrdersEqual, + shouldClearOptimisticProjectOrder, + reorderProjectOrder, +} from "./projectOrder"; +import { type Project } from "./types"; + +function makeProject(overrides: Partial = {}): Project { + return { + id: ProjectId.makeUnsafe("project-1"), + name: "Project", + cwd: "/tmp/project", + model: "gpt-5-codex", + expanded: true, + scripts: [], + ...overrides, + }; +} + +describe("projectOrder", () => { + it("supports an optimistic project order override while preserving unknown projects", () => { + const ordered = orderProjectsByIds( + [ + makeProject({ + id: ProjectId.makeUnsafe("project-a"), + cwd: "/tmp/project-a", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-b"), + cwd: "/tmp/project-b", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-c"), + cwd: "/tmp/project-c", + }), + ], + [ProjectId.makeUnsafe("project-c"), ProjectId.makeUnsafe("project-a")], + ); + + expect(ordered.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ]); + }); + + it("compares project order arrays by position", () => { + expect( + projectOrdersEqual( + [ProjectId.makeUnsafe("project-a"), ProjectId.makeUnsafe("project-b")], + [ProjectId.makeUnsafe("project-a"), ProjectId.makeUnsafe("project-b")], + ), + ).toBe(true); + expect( + projectOrdersEqual( + [ProjectId.makeUnsafe("project-a"), ProjectId.makeUnsafe("project-b")], + [ProjectId.makeUnsafe("project-b"), ProjectId.makeUnsafe("project-a")], + ), + ).toBe(false); + }); + + it("clears the optimistic order after the live project order catches up", () => { + expect( + shouldClearOptimisticProjectOrder({ + optimisticOrder: [ + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ], + currentOrder: [ + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ], + }), + ).toBe(true); + }); + + it("clears an empty optimistic order once the live project order catches up", () => { + expect( + shouldClearOptimisticProjectOrder({ + optimisticOrder: [], + currentOrder: [], + }), + ).toBe(true); + }); + + it("preserves the current project order when there is no override", () => { + const ordered = orderProjectsByIds( + [ + makeProject({ + id: ProjectId.makeUnsafe("project-b"), + name: "Project B", + cwd: "/tmp/project-b", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-a"), + name: "Project A", + cwd: "/tmp/project-a", + }), + ], + null, + ); + + expect(ordered.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-b"), + ProjectId.makeUnsafe("project-a"), + ]); + }); + + it("reorders projects by moving one project before another", () => { + expect( + reorderProjectOrder({ + currentOrder: [ + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ProjectId.makeUnsafe("project-c"), + ], + movedProjectId: ProjectId.makeUnsafe("project-c"), + beforeProjectId: ProjectId.makeUnsafe("project-a"), + }), + ).toEqual([ + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ]); + }); + + it("supports dropping a project at the end of the list", () => { + expect( + reorderProjectOrder({ + currentOrder: [ + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ProjectId.makeUnsafe("project-c"), + ], + movedProjectId: ProjectId.makeUnsafe("project-a"), + beforeProjectId: null, + }), + ).toEqual([ + ProjectId.makeUnsafe("project-b"), + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ]); + }); + + it("treats dropping a project onto itself as a no-op", () => { + expect( + reorderProjectOrder({ + currentOrder: [ + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ProjectId.makeUnsafe("project-c"), + ], + movedProjectId: ProjectId.makeUnsafe("project-b"), + beforeProjectId: ProjectId.makeUnsafe("project-b"), + }), + ).toEqual([ + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ProjectId.makeUnsafe("project-c"), + ]); + }); +}); diff --git a/apps/web/src/projectOrder.ts b/apps/web/src/projectOrder.ts new file mode 100644 index 000000000..616b7e8d0 --- /dev/null +++ b/apps/web/src/projectOrder.ts @@ -0,0 +1,81 @@ +import { type ProjectId } from "@t3tools/contracts"; + +export function projectOrdersEqual( + left: readonly T[] | null | undefined, + right: readonly T[] | null | undefined, +): boolean { + if (left === right) { + return true; + } + if (!left || !right || left.length !== right.length) { + return false; + } + return left.every((id, index) => id === right[index]); +} + +export function shouldClearOptimisticProjectOrder(input: { + optimisticOrder: readonly T[] | null | undefined; + currentOrder: readonly T[] | null | undefined; +}): boolean { + if (!input.optimisticOrder) { + return false; + } + return projectOrdersEqual(input.optimisticOrder, input.currentOrder); +} + +export function orderProjectsByIds( + projects: readonly T[], + orderedIds: readonly ProjectId[] | null | undefined, +): T[] { + if (!orderedIds || orderedIds.length === 0) { + return [...projects]; + } + + const projectById = new Map(projects.map((project) => [project.id, project] as const)); + const seenProjectIds = new Set(); + const nextProjects: T[] = []; + + for (const projectId of orderedIds) { + const project = projectById.get(projectId); + if (!project || seenProjectIds.has(project.id)) { + continue; + } + nextProjects.push(project); + seenProjectIds.add(project.id); + } + + for (const project of projects) { + if (seenProjectIds.has(project.id)) { + continue; + } + nextProjects.push(project); + } + + return nextProjects; +} + +export function reorderProjectOrder(input: { + currentOrder: readonly ProjectId[]; + movedProjectId: ProjectId; + beforeProjectId: ProjectId | null; +}): ProjectId[] { + if (input.beforeProjectId === input.movedProjectId) { + return [...input.currentOrder]; + } + const withoutMoved = input.currentOrder.filter((projectId) => projectId !== input.movedProjectId); + + if (input.beforeProjectId === null) { + return [...withoutMoved, input.movedProjectId]; + } + + const insertIndex = withoutMoved.indexOf(input.beforeProjectId); + if (insertIndex === -1) { + return [input.movedProjectId, ...withoutMoved]; + } + + return [ + ...withoutMoved.slice(0, insertIndex), + input.movedProjectId, + ...withoutMoved.slice(insertIndex), + ]; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 8dfeb210f..b290de998 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -4,7 +4,11 @@ import { useCallback, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; +import { + MAX_CUSTOM_MODEL_LENGTH, + SIDEBAR_THREAD_ORDER_OPTIONS, + useAppSettings, +} from "../appSettings"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; @@ -12,6 +16,13 @@ import { ensureNativeApi } from "../nativeApi"; import { preferredTerminalEditor } from "../terminal-links"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../components/ui/select"; import { Switch } from "../components/ui/switch"; import { SidebarInset } from "~/components/ui/sidebar"; @@ -96,6 +107,7 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const sidebarThreadOrder = settings.sidebarThreadOrder; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const openKeybindingsFile = useCallback(() => { @@ -234,6 +246,45 @@ function SettingsRouteView() {

    +
    +
    +

    Sidebar

    +

    + Choose how chats are ordered in each project. +

    +
    + + +
    +

    Codex App Server

    diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3d1d269d4..498d489e0 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -220,6 +220,120 @@ describe("derivePendingUserInputs", () => { }, ]); }); + + it("removes prompts when the provider reports an unknown pending user input request", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + }), + makeActivity({ + id: "user-input-failed", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + tone: "error", + payload: { + requestId: "req-user-input-1", + detail: + "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending user input request: req-user-input-1", + }, + }), + ]; + + expect(derivePendingUserInputs(activities)).toEqual([]); + }); + + it("removes prompts when the thread session is already in error", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + }), + ]; + + expect( + derivePendingUserInputs(activities, { + session: { status: "error" }, + }), + ).toEqual([]); + }); + + it("removes prompts when their turn has already settled", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + turnId: "turn-1", + payload: { + requestId: "req-user-input-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + }), + ]; + + expect( + derivePendingUserInputs(activities, { + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "error", + }, + }), + ).toEqual([]); + }); }); describe("deriveActivePlanState", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e9351ca2b..263193470 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -45,6 +45,11 @@ export interface PendingUserInput { questions: ReadonlyArray; } +interface PendingUserInputContext { + latestTurn?: Pick | null; + session?: Pick | null; +} + export interface ActivePlanState { createdAt: string; turnId: TurnId | null; @@ -262,8 +267,12 @@ function parseUserInputQuestions( export function derivePendingUserInputs( activities: ReadonlyArray, + context?: PendingUserInputContext, ): PendingUserInput[] { - const openByRequestId = new Map(); + const openByRequestId = new Map< + ApprovalRequestId, + PendingUserInput & { turnId: TurnId | null } + >(); const ordered = [...activities].toSorted(compareActivitiesByOrder); for (const activity of ordered) { @@ -285,18 +294,42 @@ export function derivePendingUserInputs( requestId, createdAt: activity.createdAt, questions, + turnId: activity.turnId, }); continue; } if (activity.kind === "user-input.resolved" && requestId) { openByRequestId.delete(requestId); + continue; + } + + const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; + if ( + activity.kind === "provider.user-input.respond.failed" && + requestId && + detail?.includes("Unknown pending user input request") + ) { + openByRequestId.delete(requestId); } } - return [...openByRequestId.values()].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ); + if (context?.session?.status === "error" || context?.session?.status === "closed") { + return []; + } + + const latestTurn = context?.latestTurn; + if (latestTurn && latestTurn.state !== "running") { + for (const [requestId, pending] of openByRequestId.entries()) { + if (pending.turnId === latestTurn.turnId) { + openByRequestId.delete(requestId); + } + } + } + + return [...openByRequestId.values()] + .map(({ turnId: _turnId, ...pending }) => pending) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)); } export function deriveActivePlanState( diff --git a/apps/web/src/sidebarGroupContextMenu.test.ts b/apps/web/src/sidebarGroupContextMenu.test.ts new file mode 100644 index 000000000..a263176bf --- /dev/null +++ b/apps/web/src/sidebarGroupContextMenu.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { buildSidebarGroupContextMenuItems } from "./sidebarGroupContextMenu"; + +describe("sidebarGroupContextMenu", () => { + it("builds a light menu for Main", () => { + expect( + buildSidebarGroupContextMenuItems({ + isMainGroup: true, + hasBranch: false, + hasWorktreePath: false, + hasPr: false, + }), + ).toEqual([ + { id: "open-workspace", label: "Open workspace" }, + { id: "copy-workspace-path", label: "Copy workspace path" }, + { id: "new-chat", label: "New chat" }, + ]); + }); + + it("builds a branch-only menu without destructive actions", () => { + expect( + buildSidebarGroupContextMenuItems({ + isMainGroup: false, + hasBranch: true, + hasWorktreePath: false, + hasPr: true, + }), + ).toEqual([ + { id: "open-workspace", label: "Open workspace" }, + { id: "copy-branch-name", label: "Copy branch name" }, + { id: "copy-project-path", label: "Copy project path" }, + { id: "open-pr", label: "Open PR" }, + { id: "new-chat", label: "New chat" }, + ]); + }); + + it("builds a full worktree menu with destructive action", () => { + expect( + buildSidebarGroupContextMenuItems({ + isMainGroup: false, + hasBranch: true, + hasWorktreePath: true, + hasPr: true, + }), + ).toEqual([ + { id: "open-workspace", label: "Open workspace" }, + { id: "copy-branch-name", label: "Copy branch name" }, + { id: "copy-workspace-path", label: "Copy worktree path" }, + { id: "open-pr", label: "Open PR" }, + { id: "new-chat", label: "New chat" }, + { + id: "delete-group-worktree-and-chats", + label: "Delete chats and worktree", + destructive: true, + }, + ]); + }); +}); diff --git a/apps/web/src/sidebarGroupContextMenu.ts b/apps/web/src/sidebarGroupContextMenu.ts new file mode 100644 index 000000000..8cab8b3ce --- /dev/null +++ b/apps/web/src/sidebarGroupContextMenu.ts @@ -0,0 +1,55 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; + +export type SidebarGroupContextMenuAction = + | "open-workspace" + | "copy-workspace-path" + | "copy-project-path" + | "copy-branch-name" + | "open-pr" + | "new-chat" + | "delete-group-worktree-and-chats"; + +export function buildSidebarGroupContextMenuItems(input: { + isMainGroup: boolean; + hasBranch: boolean; + hasWorktreePath: boolean; + hasPr: boolean; +}): ContextMenuItem[] { + if (input.isMainGroup) { + return [ + { id: "open-workspace", label: "Open workspace" }, + { id: "copy-workspace-path", label: "Copy workspace path" }, + { id: "new-chat", label: "New chat" }, + ]; + } + + const items: ContextMenuItem[] = [ + { id: "open-workspace", label: "Open workspace" }, + ]; + + if (input.hasBranch) { + items.push({ id: "copy-branch-name", label: "Copy branch name" }); + } + + items.push( + input.hasWorktreePath + ? { id: "copy-workspace-path", label: "Copy worktree path" } + : { id: "copy-project-path", label: "Copy project path" }, + ); + + if (input.hasPr) { + items.push({ id: "open-pr", label: "Open PR" }); + } + + items.push({ id: "new-chat", label: "New chat" }); + + if (input.hasWorktreePath) { + items.push({ + id: "delete-group-worktree-and-chats", + label: "Delete chats and worktree", + destructive: true, + }); + } + + return items; +} diff --git a/apps/web/src/sidebarReorderAnimation.test.ts b/apps/web/src/sidebarReorderAnimation.test.ts new file mode 100644 index 000000000..c76df03ad --- /dev/null +++ b/apps/web/src/sidebarReorderAnimation.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { + animateSidebarReorder, + buildSidebarReorderDeltas, + collectElementTopPositions, + hasSidebarReorderChanged, + SIDEBAR_REORDER_TRANSITION, +} from "./sidebarReorderAnimation"; + +function makeElement(top: number) { + return { + style: { + transition: "", + transform: "", + willChange: "", + }, + getBoundingClientRect: () => ({ top }), + }; +} + +describe("sidebarReorderAnimation", () => { + it("detects when the visible order changes", () => { + expect( + hasSidebarReorderChanged(["project-a", "project-b"], ["project-a", "project-b"]), + ).toBe(false); + expect( + hasSidebarReorderChanged(["project-a", "project-b"], ["project-b", "project-a"]), + ).toBe(true); + expect(hasSidebarReorderChanged(["project-a"], ["project-a", "project-b"])).toBe(true); + }); + + it("builds vertical animation deltas from previous and next row positions", () => { + expect( + buildSidebarReorderDeltas( + new Map([ + ["project-a", 20], + ["project-b", 60], + ]), + new Map([ + ["project-a", 60], + ["project-b", 20], + ]), + ), + ).toEqual([ + { id: "project-a", deltaY: -40 }, + { id: "project-b", deltaY: 40 }, + ]); + }); + + it("ignores rows without meaningful movement", () => { + expect( + buildSidebarReorderDeltas( + new Map([ + ["project-a", 20], + ["project-b", 60], + ]), + new Map([ + ["project-a", 20.2], + ["project-b", 60], + ]), + ), + ).toEqual([]); + }); + + it("collects top positions from the current rendered row map", () => { + expect( + collectElementTopPositions( + new Map([ + ["project-a", makeElement(20)], + ["project-b", makeElement(60)], + ]), + ), + ).toEqual( + new Map([ + ["project-a", 20], + ["project-b", 60], + ]), + ); + }); + + it("does not let a stale cleanup cancel a newer reorder animation", () => { + const project = makeElement(20); + const animationFrames: Array<() => void> = []; + const timeouts: Array<() => void> = []; + + animateSidebarReorder( + new Map([["project-a", project]]), + [{ id: "project-a", deltaY: 40 }], + { + requestAnimationFrame: (callback) => animationFrames.push(callback), + setTimeout: (callback) => timeouts.push(callback), + }, + ); + + animationFrames.shift()?.(); + expect(project.style.transition).toBe(SIDEBAR_REORDER_TRANSITION); + expect(project.style.transform).toBe("translateY(0)"); + + animateSidebarReorder( + new Map([["project-a", project]]), + [{ id: "project-a", deltaY: -40 }], + { + requestAnimationFrame: (callback) => animationFrames.push(callback), + setTimeout: (callback) => timeouts.push(callback), + }, + ); + + animationFrames.shift()?.(); + expect(project.style.transition).toBe(SIDEBAR_REORDER_TRANSITION); + expect(project.style.transform).toBe("translateY(0)"); + + timeouts.shift()?.(); + expect(project.style.transition).toBe(SIDEBAR_REORDER_TRANSITION); + expect(project.style.transform).toBe("translateY(0)"); + expect(project.style.willChange).toBe("transform"); + + timeouts.shift()?.(); + expect(project.style.transition).toBe(""); + expect(project.style.transform).toBe(""); + expect(project.style.willChange).toBe(""); + }); +}); diff --git a/apps/web/src/sidebarReorderAnimation.ts b/apps/web/src/sidebarReorderAnimation.ts new file mode 100644 index 000000000..7d2ea5ada --- /dev/null +++ b/apps/web/src/sidebarReorderAnimation.ts @@ -0,0 +1,117 @@ +export interface SidebarReorderDelta { + id: T; + deltaY: number; +} + +export const SIDEBAR_REORDER_TRANSITION = "transform 200ms ease-out"; + +interface SidebarAnimatableElement { + style: { + transition: string; + transform: string; + willChange: string; + }; + getBoundingClientRect(): { top: number }; +} + +interface SidebarAnimationScheduler { + requestAnimationFrame: (callback: () => void) => void; + setTimeout: (callback: () => void, delayMs: number) => void; +} + +const activeSidebarAnimationTokenByElement = new WeakMap(); + +export function hasSidebarReorderChanged( + previousOrder: ReadonlyArray, + nextOrder: ReadonlyArray, +): boolean { + if (previousOrder.length !== nextOrder.length) { + return true; + } + + return previousOrder.some((id, index) => id !== nextOrder[index]); +} + +export function buildSidebarReorderDeltas( + previousTops: ReadonlyMap, + nextTops: ReadonlyMap, +): Array> { + const deltas: Array> = []; + + for (const [id, nextTop] of nextTops.entries()) { + const previousTop = previousTops.get(id); + if (previousTop === undefined) { + continue; + } + + const deltaY = previousTop - nextTop; + if (Math.abs(deltaY) < 0.5) { + continue; + } + + deltas.push({ id, deltaY }); + } + + return deltas; +} + +export function collectElementTopPositions( + elements: ReadonlyMap, +): Map { + return new Map( + [...elements.entries()].map(([id, element]) => [id, element.getBoundingClientRect().top] as const), + ); +} + +export function animateSidebarReorder( + elements: ReadonlyMap, + deltas: ReadonlyArray>, + scheduler?: SidebarAnimationScheduler, +): void { + if (deltas.length === 0) { + return; + } + + const resolvedScheduler = scheduler ?? { + requestAnimationFrame: (callback: () => void) => window.requestAnimationFrame(callback), + setTimeout: (callback: () => void, delayMs: number) => { + window.setTimeout(callback, delayMs); + }, + }; + + for (const { id, deltaY } of deltas) { + const element = elements.get(id); + if (!element) { + continue; + } + + const nextToken = (activeSidebarAnimationTokenByElement.get(element) ?? 0) + 1; + activeSidebarAnimationTokenByElement.set(element, nextToken); + + element.style.transition = "none"; + element.style.transform = `translateY(${deltaY}px)`; + element.style.willChange = "transform"; + element.getBoundingClientRect(); + + const cleanup = () => { + if (activeSidebarAnimationTokenByElement.get(element) !== nextToken) { + return; + } + + element.style.transition = ""; + element.style.transform = ""; + element.style.willChange = ""; + activeSidebarAnimationTokenByElement.delete(element); + }; + + resolvedScheduler.requestAnimationFrame(() => { + if (activeSidebarAnimationTokenByElement.get(element) !== nextToken) { + return; + } + + element.style.transition = SIDEBAR_REORDER_TRANSITION; + element.style.transform = "translateY(0)"; + resolvedScheduler.setTimeout(cleanup, 220); + }); + } +} diff --git a/apps/web/src/sidebarThreadOrder.test.ts b/apps/web/src/sidebarThreadOrder.test.ts new file mode 100644 index 000000000..ee4a04c07 --- /dev/null +++ b/apps/web/src/sidebarThreadOrder.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import { sortSidebarThreadEntries } from "./sidebarThreadOrder"; +import { type Thread } from "./types"; + +function makeThread(overrides: Partial): Thread { + return { + id: "thread" as Thread["id"], + codexThreadId: null, + projectId: "project" as Thread["projectId"], + title: "Thread", + model: "gpt-5.4", + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + turnDiffSummaries: [], + activities: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-09T12:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + ...overrides, + }; +} + +describe("sortSidebarThreadEntries", () => { + it("sorts by recent activity when configured", () => { + const olderThread = makeThread({ + id: "thread-older" as Thread["id"], + createdAt: "2026-03-09T11:00:00.000Z", + messages: [ + { + id: "older-user" as Thread["messages"][number]["id"], + role: "user", + text: "hello", + streaming: false, + createdAt: "2026-03-09T11:01:00.000Z", + }, + ], + }); + const newerActivityThread = makeThread({ + id: "thread-new-activity" as Thread["id"], + createdAt: "2026-03-09T10:00:00.000Z", + messages: [ + { + id: "new-user" as Thread["messages"][number]["id"], + role: "user", + text: "latest", + streaming: false, + createdAt: "2026-03-09T12:30:00.000Z", + }, + ], + }); + + const ordered = sortSidebarThreadEntries( + [ + { id: olderThread.id, createdAt: olderThread.createdAt, thread: olderThread }, + { + id: newerActivityThread.id, + createdAt: newerActivityThread.createdAt, + thread: newerActivityThread, + }, + ], + "recent-activity", + ); + + expect(ordered.map((entry) => entry.id)).toEqual([ + newerActivityThread.id, + olderThread.id, + ]); + }); + + it("sorts by created-at when configured", () => { + const olderThread = makeThread({ + id: "thread-older" as Thread["id"], + createdAt: "2026-03-09T11:00:00.000Z", + messages: [ + { + id: "older-user" as Thread["messages"][number]["id"], + role: "user", + text: "latest", + streaming: false, + createdAt: "2026-03-09T12:30:00.000Z", + }, + ], + }); + const newerThread = makeThread({ + id: "thread-newer" as Thread["id"], + createdAt: "2026-03-09T12:00:00.000Z", + }); + + const ordered = sortSidebarThreadEntries( + [ + { id: olderThread.id, createdAt: olderThread.createdAt, thread: olderThread }, + { id: newerThread.id, createdAt: newerThread.createdAt, thread: newerThread }, + ], + "created-at", + ); + + expect(ordered.map((entry) => entry.id)).toEqual([newerThread.id, olderThread.id]); + }); + + it("treats proposed plans and pending questions as recent activity", () => { + const planThread = makeThread({ + id: "thread-plan" as Thread["id"], + createdAt: "2026-03-09T08:00:00.000Z", + proposedPlans: [ + { + id: "plan-1" as Thread["proposedPlans"][number]["id"], + turnId: null, + planMarkdown: "# plan", + createdAt: "2026-03-09T09:00:00.000Z", + updatedAt: "2026-03-09T12:15:00.000Z", + }, + ], + }); + const questionThread = makeThread({ + id: "thread-question" as Thread["id"], + createdAt: "2026-03-09T07:00:00.000Z", + activities: [ + { + id: "act-1" as Thread["activities"][number]["id"], + createdAt: "2026-03-09T12:20:00.000Z", + kind: "user-input.requested", + summary: "Need input", + tone: "info", + payload: { + requestId: "req-1", + questions: [ + { + id: "answer", + header: "Answer", + question: "Need input", + options: [ + { + label: "continue", + description: "Continue execution", + }, + ], + }, + ], + }, + turnId: null, + sequence: 1, + }, + ], + }); + + const ordered = sortSidebarThreadEntries( + [ + { id: planThread.id, createdAt: planThread.createdAt, thread: planThread }, + { id: questionThread.id, createdAt: questionThread.createdAt, thread: questionThread }, + ], + "recent-activity", + ); + + expect(ordered.map((entry) => entry.id)).toEqual([questionThread.id, planThread.id]); + }); +}); diff --git a/apps/web/src/sidebarThreadOrder.ts b/apps/web/src/sidebarThreadOrder.ts new file mode 100644 index 000000000..5d4551ba6 --- /dev/null +++ b/apps/web/src/sidebarThreadOrder.ts @@ -0,0 +1,73 @@ +import { derivePendingUserInputs } from "./session-logic"; +import type { Thread } from "./types"; +import type { SidebarThreadOrder } from "./appSettings"; + +export interface SidebarThreadEntryLike { + id: string; + createdAt: string; + thread: Thread | null; +} + +function latestIso(left: string, right: string): string { + return left.localeCompare(right) >= 0 ? left : right; +} + +export function getSidebarThreadRecentActivityAt(entry: SidebarThreadEntryLike): string { + const thread = entry.thread; + if (thread === null) { + return entry.createdAt; + } + + let latestActivityAt = latestIso(entry.createdAt, thread.createdAt); + + for (const message of thread.messages) { + if (message.role === "user") { + latestActivityAt = latestIso(latestActivityAt, message.createdAt); + continue; + } + + if (message.role === "assistant" && !message.streaming && message.completedAt) { + latestActivityAt = latestIso(latestActivityAt, message.completedAt); + } + } + + for (const proposedPlan of thread.proposedPlans) { + latestActivityAt = latestIso(latestActivityAt, proposedPlan.updatedAt); + } + + for ( + const pendingUserInput of derivePendingUserInputs(thread.activities, { + latestTurn: thread.latestTurn, + session: thread.session, + }) + ) { + latestActivityAt = latestIso(latestActivityAt, pendingUserInput.createdAt); + } + + return latestActivityAt; +} + +export function getSidebarThreadSortTimestamp( + entry: SidebarThreadEntryLike, + order: SidebarThreadOrder, +): string { + return order === "created-at" ? entry.createdAt : getSidebarThreadRecentActivityAt(entry); +} + +export function sortSidebarThreadEntries( + entries: readonly T[], + order: SidebarThreadOrder, +): T[] { + return [...entries].toSorted((left, right) => { + const bySortTimestamp = + getSidebarThreadSortTimestamp(right, order).localeCompare( + getSidebarThreadSortTimestamp(left, order), + ); + if (bySortTimestamp !== 0) return bySortTimestamp; + + const byCreatedAt = right.createdAt.localeCompare(left.createdAt); + if (byCreatedAt !== 0) return byCreatedAt; + + return right.id.localeCompare(left.id); + }); +} diff --git a/apps/web/src/threadGroups.test.ts b/apps/web/src/threadGroups.test.ts new file mode 100644 index 000000000..dcf222323 --- /dev/null +++ b/apps/web/src/threadGroups.test.ts @@ -0,0 +1,371 @@ +import { ProjectId, ThreadId, type GitStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + MAIN_THREAD_GROUP_ID, + buildThreadGroupId, + orderProjectThreadGroups, + resolveProjectThreadGroupPrById, + reorderProjectThreadGroupOrder, +} from "./threadGroups"; +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + model: "gpt-5-codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + messages: [], + turnDiffSummaries: [], + activities: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-01T00:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + ...overrides, + }; +} + +function makeGitStatus(overrides: Partial = {}): GitStatusResult { + return { + branch: "feature/a", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + ...overrides, + }; +} + +describe("threadGroups", () => { + it("uses worktree identity before branch identity", () => { + expect( + buildThreadGroupId({ + branch: "feature/a", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + }), + ).toBe("worktree:/tmp/project/.t3/worktrees/feature-a"); + expect(buildThreadGroupId({ branch: "feature/a", worktreePath: null })).toBe("branch:feature/a"); + expect(buildThreadGroupId({ branch: null, worktreePath: null })).toBe(MAIN_THREAD_GROUP_ID); + }); + + it("labels worktree groups from the path when branch metadata is missing", () => { + const [mainGroup, worktreeGroup] = orderProjectThreadGroups({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-main"), + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-worktree"), + branch: null, + worktreePath: "/tmp/project/.t3/worktrees/feature-draft-only", + }), + ], + }); + + expect(mainGroup?.label).toBe("Main"); + expect(worktreeGroup?.label).toBe("feature-draft-only"); + }); + + it("normalizes stored metadata used for PR resolution", () => { + const groups = orderProjectThreadGroups({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-worktree-spaced"), + branch: " feature/a ", + worktreePath: " /tmp/project/.t3/worktrees/feature-a ", + }), + ], + }); + + expect(groups[1]).toMatchObject({ + id: "worktree:/tmp/project/.t3/worktrees/feature-a", + branch: "feature/a", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + label: "feature/a", + }); + + const prByGroupId = resolveProjectThreadGroupPrById({ + groups, + projectCwd: "/tmp/project", + statusByCwd: new Map([ + [ + "/tmp/project/.t3/worktrees/feature-a", + makeGitStatus({ + branch: "feature/a", + pr: { + number: 12, + title: "Feature A", + url: "https://example.com/pr/12", + baseBranch: "main", + headBranch: "feature/a", + state: "open", + }, + }), + ], + ]), + }); + + expect(prByGroupId.get("worktree:/tmp/project/.t3/worktrees/feature-a")?.number).toBe(12); + }); + + it("refreshes group metadata when a newer thread wins the same normalized group", () => { + const groups = orderProjectThreadGroups({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-worktree-older"), + branch: null, + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + createdAt: "2026-03-01T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-worktree-newer"), + branch: "feature/a", + worktreePath: " /tmp/project/.t3/worktrees/feature-a ", + createdAt: "2026-03-05T00:00:00.000Z", + }), + ], + }); + + expect(groups[1]).toMatchObject({ + id: "worktree:/tmp/project/.t3/worktrees/feature-a", + branch: "feature/a", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + label: "feature/a", + latestActivityAt: "2026-03-05T00:00:00.000Z", + }); + }); + + it("pins Main first, inserts new groups next, then keeps shared project order", () => { + const groups = orderProjectThreadGroups({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-main"), + createdAt: "2026-03-01T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-feature-b"), + branch: "feature/b", + worktreePath: "/tmp/project/.t3/worktrees/feature-b", + createdAt: "2026-03-05T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-feature-a"), + branch: "feature/a", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + createdAt: "2026-03-04T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-release"), + branch: "release/1.0", + worktreePath: null, + createdAt: "2026-03-03T00:00:00.000Z", + }), + ], + orderedGroupIds: ["worktree:/tmp/project/.t3/worktrees/feature-a", "branch:release/1.0"], + }); + + expect(groups.map((group) => group.id)).toEqual([ + MAIN_THREAD_GROUP_ID, + "worktree:/tmp/project/.t3/worktrees/feature-b", + "worktree:/tmp/project/.t3/worktrees/feature-a", + "branch:release/1.0", + ]); + }); + + it("reorders non-main groups without losing unknown entries", () => { + expect( + reorderProjectThreadGroupOrder({ + currentOrder: ["worktree:/tmp/project/.t3/worktrees/feature-a", "branch:release/1.0"], + movedGroupId: "branch:release/1.0", + beforeGroupId: "worktree:/tmp/project/.t3/worktrees/feature-a", + }), + ).toEqual(["branch:release/1.0", "worktree:/tmp/project/.t3/worktrees/feature-a"]); + }); + + it("treats dropping a group on itself as a no-op", () => { + expect( + reorderProjectThreadGroupOrder({ + currentOrder: ["worktree:/tmp/project/.t3/worktrees/feature-a", "branch:release/1.0"], + movedGroupId: "branch:release/1.0", + beforeGroupId: "branch:release/1.0", + }), + ).toEqual(["worktree:/tmp/project/.t3/worktrees/feature-a", "branch:release/1.0"]); + }); + + it("ignores Main and duplicate ids from shared project order", () => { + const groups = orderProjectThreadGroups({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-main"), + createdAt: "2026-03-01T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-release"), + branch: "release/1.0", + worktreePath: null, + createdAt: "2026-03-03T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-feature-a"), + branch: "feature/a", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + createdAt: "2026-03-04T00:00:00.000Z", + }), + ], + orderedGroupIds: [ + MAIN_THREAD_GROUP_ID, + "branch:release/1.0", + "branch:release/1.0", + "worktree:/tmp/project/.t3/worktrees/feature-a", + ], + }); + + expect(groups.map((group) => group.id)).toEqual([ + MAIN_THREAD_GROUP_ID, + "branch:release/1.0", + "worktree:/tmp/project/.t3/worktrees/feature-a", + ]); + }); + + it("keeps reordered shared group order non-main and unique", () => { + expect( + reorderProjectThreadGroupOrder({ + currentOrder: [ + MAIN_THREAD_GROUP_ID, + "branch:release/1.0", + "branch:release/1.0", + ], + movedGroupId: "worktree:/tmp/project/.t3/worktrees/feature-a", + beforeGroupId: MAIN_THREAD_GROUP_ID, + }), + ).toEqual([ + "worktree:/tmp/project/.t3/worktrees/feature-a", + "branch:release/1.0", + ]); + }); + + it("includes draft-only groups in ordering", () => { + const groups = orderProjectThreadGroups({ + threads: [ + { + branch: "feature/draft-only", + worktreePath: "/tmp/project/.t3/worktrees/feature-draft-only", + createdAt: "2026-03-06T00:00:00.000Z", + }, + ], + }); + + expect(groups.map((group) => group.id)).toEqual([ + MAIN_THREAD_GROUP_ID, + "worktree:/tmp/project/.t3/worktrees/feature-draft-only", + ]); + }); + + it("resolves PR state for worktree and branch groups but never Main", () => { + const groups = orderProjectThreadGroups({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-main"), + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-branch"), + branch: "feature/a", + worktreePath: null, + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-worktree"), + branch: "feature/b", + worktreePath: "/tmp/project/.t3/worktrees/feature-b", + }), + ], + }); + + const prByGroupId = resolveProjectThreadGroupPrById({ + groups, + projectCwd: "/tmp/project", + statusByCwd: new Map([ + [ + "/tmp/project", + makeGitStatus({ + branch: "feature/a", + pr: { + number: 12, + title: "Feature A", + url: "https://example.com/pr/12", + baseBranch: "main", + headBranch: "feature/a", + state: "open", + }, + }), + ], + [ + "/tmp/project/.t3/worktrees/feature-b", + makeGitStatus({ + branch: "feature/b", + pr: { + number: 34, + title: "Feature B", + url: "https://example.com/pr/34", + baseBranch: "main", + headBranch: "feature/b", + state: "merged", + }, + }), + ], + ]), + }); + + expect(prByGroupId.get(MAIN_THREAD_GROUP_ID)).toBeNull(); + expect(prByGroupId.get("branch:feature/a")?.number).toBe(12); + expect(prByGroupId.get("worktree:/tmp/project/.t3/worktrees/feature-b")?.number).toBe(34); + }); + + it("omits group PR state when the git status branch does not match the group branch", () => { + const groups = orderProjectThreadGroups({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-worktree"), + branch: "feature/b", + worktreePath: "/tmp/project/.t3/worktrees/feature-b", + }), + ], + }); + + const prByGroupId = resolveProjectThreadGroupPrById({ + groups, + projectCwd: "/tmp/project", + statusByCwd: new Map([ + [ + "/tmp/project/.t3/worktrees/feature-b", + makeGitStatus({ + branch: "feature/c", + pr: { + number: 99, + title: "Wrong Branch", + url: "https://example.com/pr/99", + baseBranch: "main", + headBranch: "feature/c", + state: "closed", + }, + }), + ], + ]), + }); + + expect(prByGroupId.get("worktree:/tmp/project/.t3/worktrees/feature-b")).toBeNull(); + }); +}); diff --git a/apps/web/src/threadGroups.ts b/apps/web/src/threadGroups.ts new file mode 100644 index 000000000..20fbeb3a0 --- /dev/null +++ b/apps/web/src/threadGroups.ts @@ -0,0 +1,180 @@ +import type { GitStatusResult } from "@t3tools/contracts"; +import { formatWorktreePathForDisplay } from "./worktreeCleanup"; + +export type ThreadGroupId = string; +export const MAIN_THREAD_GROUP_ID: ThreadGroupId = "main"; + +export interface ThreadGroupIdentity { + branch: string | null; + worktreePath: string | null; +} + +export interface ThreadGroupSeed extends ThreadGroupIdentity { + createdAt: string; +} + +export interface OrderedProjectThreadGroup { + id: ThreadGroupId; + branch: string | null; + worktreePath: string | null; + label: string; + latestActivityAt: string; +} + +export type ThreadGroupPrStatus = GitStatusResult["pr"]; + +function normalizeWorktreePath(worktreePath: string): string { + return worktreePath.trim(); +} + +function normalizeBranchName(branch: string): string { + return branch.trim(); +} + +function normalizeThreadGroupIdentity(input: ThreadGroupIdentity): ThreadGroupIdentity { + const branch = input.branch ? normalizeBranchName(input.branch) : null; + const worktreePath = input.worktreePath ? normalizeWorktreePath(input.worktreePath) : null; + return { + branch: branch && branch.length > 0 ? branch : null, + worktreePath: worktreePath && worktreePath.length > 0 ? worktreePath : null, + }; +} + +function normalizeProjectThreadGroupOrder(threadGroupOrder: readonly ThreadGroupId[]): ThreadGroupId[] { + const seen = new Set(); + const next: ThreadGroupId[] = []; + for (const groupId of threadGroupOrder) { + if (groupId === MAIN_THREAD_GROUP_ID || seen.has(groupId)) { + continue; + } + seen.add(groupId); + next.push(groupId); + } + return next; +} + +export function buildThreadGroupId(input: ThreadGroupIdentity): ThreadGroupId { + const normalized = normalizeThreadGroupIdentity(input); + if (normalized.worktreePath) { + return `worktree:${normalized.worktreePath}`; + } + if (normalized.branch) { + return `branch:${normalized.branch}`; + } + return MAIN_THREAD_GROUP_ID; +} + +function threadGroupLabel(input: ThreadGroupIdentity): string { + if (input.worktreePath) { + return input.branch ?? formatWorktreePathForDisplay(input.worktreePath); + } + if (input.branch) { + return input.branch; + } + return "Main"; +} + +export function orderProjectThreadGroups(input: { + threads: T[]; + orderedGroupIds?: readonly ThreadGroupId[] | null | undefined; +}): OrderedProjectThreadGroup[] { + const groups = new Map(); + for (const thread of input.threads) { + const identity = normalizeThreadGroupIdentity({ + branch: thread.branch, + worktreePath: thread.worktreePath, + }); + const id = buildThreadGroupId(identity); + const existing = groups.get(id); + if (!existing) { + groups.set(id, { + id, + branch: identity.branch, + worktreePath: identity.worktreePath, + label: threadGroupLabel(identity), + latestActivityAt: thread.createdAt, + }); + continue; + } + if (thread.createdAt > existing.latestActivityAt) { + existing.latestActivityAt = thread.createdAt; + existing.branch = identity.branch; + existing.worktreePath = identity.worktreePath; + existing.label = threadGroupLabel(identity); + } + } + + const mainGroup = + groups.get(MAIN_THREAD_GROUP_ID) ?? + ({ + id: MAIN_THREAD_GROUP_ID, + branch: null, + worktreePath: null, + label: "Main", + latestActivityAt: "", + } satisfies OrderedProjectThreadGroup); + + const nonMainGroups = [...groups.values()].filter((group) => group.id !== MAIN_THREAD_GROUP_ID); + const normalizedProjectThreadGroupOrder = normalizeProjectThreadGroupOrder(input.orderedGroupIds ?? []); + const orderedKnownIds = new Set(normalizedProjectThreadGroupOrder); + const newGroups = nonMainGroups + .filter((group) => !orderedKnownIds.has(group.id)) + .toSorted((left, right) => right.latestActivityAt.localeCompare(left.latestActivityAt)); + const knownGroups = normalizedProjectThreadGroupOrder + .map((groupId) => groups.get(groupId)) + .filter((group): group is OrderedProjectThreadGroup => group !== undefined); + + return [mainGroup, ...newGroups, ...knownGroups]; +} + +export function reorderProjectThreadGroupOrder(input: { + currentOrder: ThreadGroupId[]; + movedGroupId: ThreadGroupId; + beforeGroupId: ThreadGroupId | null; +}): ThreadGroupId[] { + const normalizedCurrentOrder = normalizeProjectThreadGroupOrder(input.currentOrder); + if (input.movedGroupId === MAIN_THREAD_GROUP_ID) { + return normalizedCurrentOrder; + } + if (input.beforeGroupId === input.movedGroupId) { + return normalizedCurrentOrder; + } + const withoutMoved = normalizedCurrentOrder.filter((groupId) => groupId !== input.movedGroupId); + if (input.beforeGroupId === MAIN_THREAD_GROUP_ID) { + return [input.movedGroupId, ...withoutMoved]; + } + if (!input.beforeGroupId) { + return [...withoutMoved, input.movedGroupId]; + } + const insertIndex = withoutMoved.indexOf(input.beforeGroupId); + if (insertIndex === -1) { + return [input.movedGroupId, ...withoutMoved]; + } + return [ + ...withoutMoved.slice(0, insertIndex), + input.movedGroupId, + ...withoutMoved.slice(insertIndex), + ]; +} + +export function resolveProjectThreadGroupPrById(input: { + groups: readonly OrderedProjectThreadGroup[]; + projectCwd: string; + statusByCwd: ReadonlyMap; +}): Map { + const prByGroupId = new Map(); + + for (const group of input.groups) { + if (group.id === MAIN_THREAD_GROUP_ID || group.branch === null) { + prByGroupId.set(group.id, null); + continue; + } + + const cwd = group.worktreePath ?? input.projectCwd; + const status = input.statusByCwd.get(cwd); + const branchMatches = status?.branch !== null && status?.branch === group.branch; + prByGroupId.set(group.id, branchMatches ? (status?.pr ?? null) : null); + } + + return prByGroupId; +}