diff --git a/src/browser/App.tsx b/src/browser/App.tsx index f599e83d551..6fb376dd4c0 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -75,6 +75,7 @@ import { EXPANDED_PROJECTS_KEY, LEFT_SIDEBAR_COLLAPSED_KEY, LEFT_SIDEBAR_WIDTH_KEY, + SIDEBAR_FLAT_MODE_KEY, } from "@/common/constants/storage"; import { normalizeToCanonical } from "@/common/utils/ai/models"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; @@ -961,7 +962,10 @@ function AppInner() { meta, direction, sortedWorkspacesByProject, - userProjects + userProjects, + readPersistedState(SIDEBAR_FLAT_MODE_KEY, false) + ? { multiProjectEnabled: multiProjectWorkspacesEnabled } + : false ); if (order) void reorderPinnedWorkspaces(order); }, @@ -970,6 +974,7 @@ function AppInner() { workspaceMetadata, sortedWorkspacesByProject, userProjects, + multiProjectWorkspacesEnabled, reorderPinnedWorkspaces, ] ); diff --git a/src/browser/components/AgentListItem/AgentListItem.test.tsx b/src/browser/components/AgentListItem/AgentListItem.test.tsx index f75f0037361..8c0e8437503 100644 --- a/src/browser/components/AgentListItem/AgentListItem.test.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.test.tsx @@ -48,6 +48,7 @@ type MockWorkspaceUnreadState = ReturnType; let mockWorkspaceHeartbeatsEnabled = false; +let latestUseDragSpec: (() => { item?: () => Record }) | null = null; let mockWorkspaceUnreadState: MockWorkspaceUnreadState; let mockWorkspaceSidebarState: MockWorkspaceSidebarState; @@ -141,6 +142,14 @@ function installAgentListItemTestDoubles() { spyOn(TooltipModule, "TooltipContent").mockImplementation(((props: { children: ReactNode }) => ( <>{props.children} )) as unknown as typeof TooltipModule.TooltipContent); + spyOn(TooltipModule, "TooltipIfPresent").mockImplementation(((props: { + children: ReactNode; + tooltip?: string; + }) => ( + + {props.children} + + )) as unknown as typeof TooltipModule.TooltipIfPresent); spyOn(WorkspaceStatusIndicatorModule, "WorkspaceStatusIndicator").mockImplementation(((props: { workspaceId: string; }) => ( @@ -172,7 +181,10 @@ function installAgentListItemTestDoubles() { void mock.module("react-dnd", () => ({ ...actualReactDnd, - useDrag: () => [{ isDragging: false }, passthroughRef, () => undefined] as const, + useDrag: (spec: () => { item?: () => Record }) => { + latestUseDragSpec = spec; + return [{ isDragging: false }, passthroughRef, () => undefined] as const; + }, useDrop: () => [{ isPinnedReorderTarget: false }, passthroughRef] as const, })); @@ -275,6 +287,7 @@ function renderWorkspaceItem( completedChildrenExpanded?: boolean; onToggleCompletedChildren?: (workspaceId: string) => void; onSelectWorkspace?: (selection: WorkspaceSelection) => void; + projectBadgeName?: string; } = {} ) { const metadata = options.metadata ?? createMetadata(); @@ -283,6 +296,7 @@ function renderWorkspaceItem( metadata={metadata} projectPath={metadata.projectPath} projectName={metadata.projectName} + projectBadgeName={options.projectBadgeName} isSelected={options.isSelected ?? false} isArchiving={options.isArchiving} depth={options.depth ?? options.rowRenderMeta?.depth} @@ -338,6 +352,29 @@ describe("AgentListItem", () => { mock.restore(); }); + test("exposes the full project badge label through the shared tooltip", () => { + // The badge's width cap end-truncates hierarchical "Parent / Sub" names, + // so the shared tooltip wrapper must carry the full label. + const badgeName = "Parent Project With A Long Name / Frontend"; + const { view } = renderWorkspaceItem({ projectBadgeName: badgeName }); + + const badge = view.getByTestId(`workspace-project-badge-${TEST_WORKSPACE_ID}`); + const tooltip = badge.closest('[data-testid="badge-tooltip"]'); + expect(tooltip?.getAttribute("data-tooltip-content")).toBe(badgeName); + }); + + test("falls back to the row's sub-project scope for drag section identity", () => { + // Flat rows omit the sectionId prop (it also drives section indentation), + // so the drag item must carry metadata.subProjectPath instead; drop zones + // rely on it to treat same-section drops as no-ops. + renderWorkspaceItem({ + metadata: createMetadata({ subProjectPath: "/tmp/project/features" }), + }); + const item = latestUseDragSpec?.().item?.(); + expect(item?.workspaceId).toBe(TEST_WORKSPACE_ID); + expect(item?.currentSectionId).toBe("/tmp/project/features"); + }); + test("suppresses best-of member titles that repeat the group header (D8)", () => { const candidate = renderWorkspaceItem({ metadata: createMetadata({ diff --git a/src/browser/components/AgentListItem/AgentListItem.tsx b/src/browser/components/AgentListItem/AgentListItem.tsx index 3b3cea3fb29..90301787d6b 100644 --- a/src/browser/components/AgentListItem/AgentListItem.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.tsx @@ -37,7 +37,7 @@ import { type VisualState, } from "./StatusDot"; -import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent, TooltipIfPresent } from "../Tooltip/Tooltip"; import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from "../Popover/Popover"; import { PositionedMenu, PositionedMenuItem } from "../PositionedMenu/PositionedMenu"; import { @@ -105,6 +105,9 @@ interface AgentListItemBaseProps { isSelected: boolean; depth?: number; sectionId?: string; + // Stable primitives (not an object) so React Compiler can skip unchanged rows. + projectBadgeName?: string; + projectBadgeColor?: string; } /** Props for regular (persisted) workspace items */ @@ -471,7 +474,11 @@ function DraftAgentListItemInner(props: DraftAgentListItemProps) { role="button" tabIndex={0} aria-current={isSelected ? "true" : undefined} - aria-label={`Open workspace draft ${draft.draftNumber}`} + aria-label={ + props.projectBadgeName != null + ? `Open workspace draft ${draft.draftNumber} (${props.projectBadgeName})` + : `Open workspace draft ${draft.draftNumber}` + } data-project-path={projectPath} data-draft-id={draft.draftId} > @@ -490,6 +497,26 @@ function DraftAgentListItemInner(props: DraftAgentListItemProps) { > {draft.title} + {props.projectBadgeName != null && ( + // The badge width cap can truncate hierarchical "Parent / Sub" + // names, so the shared tooltip keeps the full label reachable. + + + {props.projectBadgeName} + + + )} {hasPromptPreview && ( { + // The explicit label overrides descendant badge text, so include the + // project identity whenever the badge is the only visible project cue. + const accessibleTitle = + props.projectBadgeName != null + ? `${displayTitle} (${props.projectBadgeName})` + : displayTitle; + return isRemoving + ? `Deleting workspace ${accessibleTitle}` : isInitializing - ? `Initializing workspace ${displayTitle}` + ? `Initializing workspace ${accessibleTitle}` : isArchiving - ? `Archiving workspace ${displayTitle}` - : `Select workspace ${displayTitle}` - } + ? `Archiving workspace ${accessibleTitle}` + : `Select workspace ${accessibleTitle}`; + })()} aria-describedby={secondaryStatusDescriptionId} aria-disabled={isDisabled} data-workspace-path={namedWorkspacePath} @@ -1319,6 +1356,26 @@ function RegularAgentListItemInner(props: AgentListItemProps) { > {suppressGroupMemberTitle ? memberOnlyLabel : workspaceTitle} + {props.projectBadgeName != null && ( + // The badge width cap can truncate hierarchical "Parent / Sub" + // names, so the shared tooltip keeps the full label reachable. + + + {props.projectBadgeName} + + + )} {groupLabel && !suppressGroupMemberTitle && ( ( + { + updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true); + // Keep the sidebar visible at the phone width: no selected workspace + // (mobile shows the chat over the sidebar) and the sidebar expanded. + clearWorkspaceSelection(); + collapseRightSidebar(); + updatePersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, false); + const workspaces = [ + createWorkspace({ + id: "alpha-pinned", + name: "alpha-pinned", + title: "Pinned from a long project name", + projectName: "alpha-application-with-a-long-name", + pinnedAt: "2026-01-02T00:00:00.000Z", + }), + createWorkspace({ + id: "beta-pinned", + name: "beta-pinned", + title: "Pinned beta chat", + projectName: "beta-service", + pinnedAt: "2026-01-01T00:00:00.000Z", + }), + createWorkspace({ + id: "alpha-recent", + name: "alpha-recent", + title: "Recent alpha work", + projectName: "alpha-application-with-a-long-name", + }), + { + ...createWorkspace({ + id: "scratch-flat", + name: "scratch-flat", + title: "Scratch idea", + projectName: "Scratch", + projectPath: "/home/user/.xum/scratch/scratch-flat", + }), + kind: "scratch" as const, + }, + ]; + const projects = groupWorkspacesByProject(workspaces); + const alphaPath = "/home/user/projects/alpha-application-with-a-long-name"; + const betaPath = "/home/user/projects/beta-service"; + const alphaConfig = projects.get(alphaPath); + const betaConfig = projects.get(betaPath); + if (alphaConfig) projects.set(alphaPath, { ...alphaConfig, color: "Blue" }); + if (betaConfig) projects.set(betaPath, { ...betaConfig, color: "Green" }); + return createMockORPCClient({ projects, workspaces }); + }} + /> + ), + // Contract: the flat list (badges) and the project management headers are + // actually on screen, so a viewport variant cannot silently snapshot the + // wrong UI (e.g. the sidebar hidden behind a selected chat on mobile). + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitFor(() => { + if (!canvasElement.querySelector('[data-testid="workspace-project-badge-alpha-pinned"]')) { + throw new Error("Expected a project badge on a flat-list chat row"); + } + if ( + !canvasElement.querySelector( + 'button[aria-label="Project options for alpha-application-with-a-long-name"]' + ) + ) { + throw new Error("Expected project management headers below the flat list"); + } + }); + }, +}; // Pinned chats sort by pinnedAt (user-reorderable), not by name or recency: // the pinned block deliberately renders as charlie, alpha, bravo while the // newest unpinned chat stays below the block. diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 0d63abc88b0..a7c6b63296b 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -7,7 +7,11 @@ import * as ReactDndModule from "react-dnd"; import * as ReactDndHtml5BackendModule from "react-dnd-html5-backend"; import * as ReactColorfulModule from "react-colorful"; import { installDom } from "../../../../tests/ui/dom"; -import { EXPANDED_PROJECTS_KEY, SIDEBAR_HIDE_SUBAGENTS_KEY } from "@/common/constants/storage"; +import { + EXPANDED_PROJECTS_KEY, + SIDEBAR_FLAT_MODE_KEY, + SIDEBAR_HIDE_SUBAGENTS_KEY, +} from "@/common/constants/storage"; import { getDraftScopeId, getInputKey } from "@/common/constants/storage"; import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_SIDEBAR_SECTION_ID } from "@/common/constants/scratch"; import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProject"; @@ -123,6 +127,7 @@ interface MockAgentListItemProps { }; depth?: number; rowRenderMeta?: AgentRowRenderMeta; + projectBadgeName?: string; delegatedActivity?: { activeCount: number; queuedCount: number }; hiddenSubAgentsSummary?: WorkspaceSubAgentsSummary; getWorkflowRunName?: (runId: string) => string | undefined; @@ -311,6 +316,7 @@ function installProjectSidebarTestDoubles() { return (
{props.draft.title ?? "Draft"} + {props.projectBadgeName != null && {props.projectBadgeName}}
); } @@ -369,6 +375,7 @@ function installProjectSidebarTestDoubles() { )} > {displayTitle} + {props.projectBadgeName != null ? {props.projectBadgeName} : null} {hasCompletedChildren && props.onToggleCompletedChildren ? ( + {isTierExpanded && ( + <> + {renderWorkspaceRows(bucket)} + {(() => { + const nextTier = findNextNonEmptyTier(buckets, tierIndex + 1); + return nextTier !== -1 ? renderTier(nextTier) : null; + })()} + + )} + + ); + }; + + return ( + <> + {renderWorkspaceRows(topVisibleRows)} + {firstTier !== -1 && renderTier(firstTier)} + + ); + }; + + const renderFlatRow = ( + metadata: FrontendWorkspaceMetadata, + opts?: CoalescedRowRenderOptions + ): React.ReactNode => { + const rowRenderMeta = + opts?.rowRenderMeta === undefined + ? flatRowMetaByWorkspaceId.get(metadata.id) + : (opts.rowRenderMeta ?? undefined); + const badge = getFlatProjectBadge(metadata); + return ( + + ); + }; + + const flatSidebarContent = ( +
+ + {renderCoalescedWorkspaceList(visiblePinnedFlatWorkspaces, flatRowsForDisplay, { + tierKeyPrefix: "flat", + depthByWorkspaceId: flatDepthByWorkspaceId, + baseRowMetaByWorkspaceId: flatRowMetaByWorkspaceId, + renderRow: renderFlatRow, + })} + {flatDrafts.map(({ projectPath, draft }, index) => { + const promotedMetadata = flatDraftPromotionsByDraftId.get(draft.draftId); + if (promotedMetadata) { + // The just-created workspace renders in the draft's position while + // the draft entry is still present (grouped mode does the same). + const liveMetadata = + flatWorkspaces.find((workspace) => workspace.id === promotedMetadata.id) ?? + promotedMetadata; + return {renderFlatRow(liveMetadata)}; + } + const isSelected = + pendingNewWorkspaceProject === projectPath && + pendingNewWorkspaceDraftId === draft.draftId; + const draftBadge = getFlatDraftBadge(projectPath, draft.subProjectPath); + return ( + { + handleDraftVisibilityChange(projectPath, draft.draftId, isVisible); + }} + onOpen={() => handleOpenWorkspaceDraft(projectPath, draft.draftId)} + onDelete={() => { + if (isSelected) { + // Mirror grouped-mode deletion: hand selection to an adjacent + // remaining draft, falling back to the project route only when + // this was the last one. + const fallback = flatDrafts[index + 1] ?? flatDrafts[index - 1]; + if (fallback) { + openWorkspaceDraft(fallback.projectPath, fallback.draft.draftId); + } else { + navigateToProject(projectPath); + } + } + deleteWorkspaceDraft(projectPath, draft.draftId); + }} + /> + ); + })} + {renderCoalescedWorkspaceList(visibleUnpinnedFlatWorkspaces, flatRowsForDisplay, { + tierKeyPrefix: "flat", + depthByWorkspaceId: flatDepthByWorkspaceId, + baseRowMetaByWorkspaceId: flatRowMetaByWorkspaceId, + renderRow: renderFlatRow, + })} +
+ ); + return ( = ({ onViewportScroll={handleProjectListScroll} viewportClassName="overflow-x-hidden" > -
-
- -
- Chats - {(scratchWorkspaces.length > 0 || scratchDrafts.length > 0) && ( - - ({topLevelScratchWorkspaces.length + scratchDrafts.length}) - - )} -
- - - - - New scratch chat - -
- {isScratchSectionExpanded && ( -
- {scratchDrafts.map((draft, index) => { - const isSelected = - pendingNewWorkspaceProject === SCRATCH_PROJECT_CONFIG_KEY && - pendingNewWorkspaceDraftId === draft.draftId; - return ( - { - handleDraftVisibilityChange( - SCRATCH_PROJECT_CONFIG_KEY, - draft.draftId, - isVisible - ); - }} - onOpen={() => - handleOpenWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY, draft.draftId) - } - onDelete={() => { - if (isSelected) { - navigateToProject(SCRATCH_PROJECT_CONFIG_KEY); - } - deleteWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY, draft.draftId); + /> + +
+ Chats + {(scratchWorkspaces.length > 0 || scratchDrafts.length > 0) && ( + + ({topLevelScratchWorkspaces.length + scratchDrafts.length}) + + )} +
+ + + - )} + aria-label="New scratch chat" + className="text-content-secondary hover:bg-hover hover:border-border-light flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded border border-transparent bg-transparent" + > + + + + New scratch chat +
- )} -
+ {isScratchSectionExpanded && ( +
+ {scratchDrafts.map((draft, index) => { + const isSelected = + pendingNewWorkspaceProject === SCRATCH_PROJECT_CONFIG_KEY && + pendingNewWorkspaceDraftId === draft.draftId; + return ( + { + handleDraftVisibilityChange( + SCRATCH_PROJECT_CONFIG_KEY, + draft.draftId, + isVisible + ); + }} + onOpen={() => + handleOpenWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY, draft.draftId) + } + onDelete={() => { + if (isSelected) { + navigateToProject(SCRATCH_PROJECT_CONFIG_KEY); + } + deleteWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY, draft.draftId); + }} + /> + ); + })} + {visibleScratchWorkspaces.map((metadata) => { + const rowRenderMeta = scratchRowMetaByWorkspaceId.get(metadata.id); + return ( + + ); + })} + {scratchWorkspaces.length === 0 && scratchDrafts.length === 0 && ( + + )} +
+ )} + + )} - {multiProjectWorkspaces.length > 0 && ( + {!flatSidebarEnabled && multiProjectWorkspaces.length > 0 && (
)} - {sortedProjectPaths.length === 0 && multiProjectWorkspaces.length === 0 ? ( + {!flatSidebarEnabled && + projectHeaderPaths.length === 0 && + multiProjectWorkspaces.length === 0 ? (

No projects

@@ -2232,30 +2924,55 @@ const ProjectSidebarInner: React.FC = ({
) : ( - sortedProjectPaths.map((projectPath) => { - const config = userProjects.get(projectPath); - if (!config) return null; - const projectFolderColor = config.color - ? resolveSectionColor(config.color) - : undefined; - const projectName = getProjectNameFromPath(projectPath); - const sanitizedProjectId = - projectPath.replace(/[^a-zA-Z0-9_-]/g, "-") || "root"; - const workspaceListId = `workspace-list-${sanitizedProjectId}`; - const isExpanded = expandedProjectsList.includes(projectPath); - const displayProjectName = - config.displayName ?? getProjectFallbackLabel(projectPath); - const isEditingProjectDisplayName = editingProjectPath === projectPath; - const projectWorkspaces = - singleProjectWorkspacesByProject.get(projectPath) ?? []; - const topLevelProjectWorkspaces = excludeSubAgentRows(projectWorkspaces); - const projectAgentCount = topLevelProjectWorkspaces.length; - const projectHasAttention = projectWorkspaces.some( - (workspace) => workspaceAttentionById.get(workspace.id) === true - ); - - return ( -
+ <> + {flatSidebarEnabled && projectHeaderPaths.length > 0 && ( +
+ Projects +
+ )} + {projectHeaderPaths.map((projectPath) => { + const config = userProjects.get(projectPath); + if (!config) return null; + const projectFolderColor = config.color + ? resolveSectionColor(config.color) + : undefined; + const projectName = getProjectNameFromPath(projectPath); + const sanitizedProjectId = + projectPath.replace(/[^a-zA-Z0-9_-]/g, "-") || "root"; + const workspaceListId = `workspace-list-${sanitizedProjectId}`; + const isExpanded = expandedProjectsList.includes(projectPath); + const displayProjectName = + config.displayName ?? getProjectFallbackLabel(projectPath); + const isEditingProjectDisplayName = editingProjectPath === projectPath; + const projectWorkspaces = + singleProjectWorkspacesByProject.get(projectPath) ?? []; + const topLevelProjectWorkspaces = excludeSubAgentRows(projectWorkspaces); + const projectAgentCount = topLevelProjectWorkspaces.length; + const projectHasAttention = projectWorkspaces.some( + (workspace) => workspaceAttentionById.get(workspace.id) === true + ); + + // Shared by the grouped section drop zones and the flat + // management rows so chats can be assigned to (or cleared + // from) a sub-project in both modes. + const handleWorkspaceSectionDrop = ( + workspaceId: string, + targetSectionId: string | null + ) => { + void (async () => { + const result = await assignWorkspaceToSubProject( + projectPath, + workspaceId, + targetSectionId + ); + if (result.success) { + // Refresh workspace metadata so UI shows updated sectionId + await refreshWorkspaceMetadata(); + } + })(); + }; + + const projectHeaderRow = ( = ({ }} role="button" tabIndex={0} - aria-expanded={isExpanded} - aria-controls={workspaceListId} + aria-expanded={flatSidebarEnabled ? undefined : isExpanded} + aria-controls={flatSidebarEnabled ? undefined : workspaceListId} aria-label={`Create workspace in ${projectName}`} data-project-path={projectPath} > - + ) : ( + + )}
handleOpenProjectMenu(event, projectPath)} @@ -2437,926 +3167,558 @@ const ProjectSidebarInner: React.FC = ({ Project options + ); + + return ( +
+ {flatSidebarEnabled ? ( + // Dropping a chat on the project header clears its + // sub-project assignment, mirroring the grouped + // unsectioned drop zone. + + {projectHeaderRow} + + ) : ( + projectHeaderRow + )} - {isExpanded && ( -
- {(() => { - // Archived workspaces are excluded from workspaceMetadata so won't appear here - - const draftsForProject = workspaceDraftsByProject[projectPath] ?? []; - const activeDraftIds = new Set( - draftsForProject.map((draft) => draft.draftId) - ); - const draftPromotionsForProject = - workspaceDraftPromotionsByProject[projectPath] ?? {}; - const activeDraftPromotions = Object.fromEntries( - Object.entries(draftPromotionsForProject).filter(([draftId]) => - activeDraftIds.has(draftId) - ) - ); - const promotedWorkspaceIds = new Set( - Object.values(activeDraftPromotions).map((metadata) => metadata.id) - ); - const projectRowsForDisplay = hideSubAgentRows - ? topLevelProjectWorkspaces - : projectWorkspaces; - const workspacesForNormalRendering = projectRowsForDisplay.filter( - (workspace) => !promotedWorkspaceIds.has(workspace.id) - ); - const sections: SectionConfig[] = getSubProjectsForParent( - projectPath, - userProjects - ).map(([subProjectPath, subProjectConfig]) => ({ - id: subProjectPath, - name: getProjectDisplayName(subProjectPath, subProjectConfig), - color: subProjectConfig.color, - })); - const depthByWorkspaceId = - computeWorkspaceDepthMap(projectWorkspaces); - // Track runs that are (or were, this session) active so their - // groups stay mounted across step gaps where every member is - // momentarily terminal (no flash-out between sequential steps). - for (const key of collectActiveWorkflowGroupKeys( - workspacesForNormalRendering, - { isWorkspaceLiveActive } - )) { - sessionActiveTaskGroupKeysRef.current.add(key); - } - const visibleWorkspacesForNormalRendering = filterVisibleAgentRows( - workspacesForNormalRendering, - expandedCompletedParentIds, - { isWorkspaceLiveActive } - ); - const baseRowMetaByWorkspaceId = computeAgentRowRenderMeta( - workspacesForNormalRendering, - depthByWorkspaceId, - expandedCompletedParentIds, - { isWorkspaceLiveActive } - ); - const sortedDrafts = draftsForProject - .slice() - .sort((a, b) => b.createdAt - a.createdAt); - const draftVisibilityForProject = - draftVisibilityByProject[projectPath] ?? {}; - const hasVisibleDrafts = sortedDrafts.some((draft) => { - const reactiveVisibility = draftVisibilityForProject[draft.draftId]; - return ( - reactiveVisibility ?? isDraftVisible(projectPath, draft.draftId) + {/* Flat mode keeps sub-project management reachable: + the same SectionHeader controls (scoped new chat, + rename, color, delete) render as compact rows with + nothing nested beneath them. */} + {flatSidebarEnabled && + getSubProjectsForParent(projectPath, userProjects).map( + ([subProjectPath, subProjectConfig]) => { + const sectionRows = topLevelProjectWorkspaces.filter( + (workspace) => workspace.subProjectPath === subProjectPath ); - }); - const projectHasNoAgentsOrDrafts = - projectWorkspaces.length === 0 && !hasVisibleDrafts; - const draftNumberById = new Map( - sortedDrafts.map( - (draft, index) => [draft.draftId, index + 1] as const - ) - ); - const getDraftSectionId = ( - draft: (typeof sortedDrafts)[number] - ): string | null => - typeof draft.subProjectPath === "string" && - userProjects.get(draft.subProjectPath)?.parentProjectPath === - projectPath - ? draft.subProjectPath - : null; - - // Drafts can reference a section that has since been deleted. - // Treat those as unsectioned so they remain accessible. - const unsectionedDrafts: typeof sortedDrafts = []; - const draftsBySectionId = new Map(); - for (const draft of sortedDrafts) { - const sectionId = getDraftSectionId(draft); - if (sectionId === null) { - unsectionedDrafts.push(draft); - continue; - } - - const existing = draftsBySectionId.get(sectionId); - if (existing) { - existing.push(draft); - } else { - draftsBySectionId.set(sectionId, [draft]); - } - } - - const renderWorkspace = ( - metadata: FrontendWorkspaceMetadata, - sectionId?: string, - rowRenderMetaOverride?: AgentRowRenderMeta | null, - depthOverride?: number, - keyOverride?: string, - subAgentConnectorLayout?: "default" | "task-group-member", - taskGroupHeaderTitle?: string - ) => { - const rowRenderMeta = - rowRenderMetaOverride === undefined - ? baseRowMetaByWorkspaceId.get(metadata.id) - : (rowRenderMetaOverride ?? undefined); - + // Grouped mode counts a section's drafts too; + // mirror that so toggling modes never changes + // the displayed count. + const sectionDraftCount = ( + workspaceDraftsByProject[projectPath] ?? [] + ).filter((draft) => draft.subProjectPath === subProjectPath).length; + const shouldAutoEditSection = + autoEditingSection?.projectPath === projectPath && + autoEditingSection?.sectionId === subProjectPath; return ( - + sectionId={subProjectPath} + onDrop={handleWorkspaceSectionDrop} + className="pl-4" + > + + workspaceAttentionById.get(workspace.id) === true + )} + onAddWorkspace={() => { + handleAddWorkspace(projectPath, subProjectPath); + }} + onRename={(name) => { + if (shouldAutoEditSection) { + setAutoEditingSection(null); + } + void updateDisplayName(subProjectPath, name); + }} + onChangeColor={(color) => { + void updateProjectColor(subProjectPath, color); + }} + autoStartEditing={shouldAutoEditSection} + onAutoCreateAbandon={ + shouldAutoEditSection + ? () => { + void (async () => { + setAutoEditingSection(null); + await handleRemoveSection( + projectPath, + subProjectPath + ); + })(); + } + : undefined + } + onAutoCreateRenameCancel={ + shouldAutoEditSection + ? () => { + setAutoEditingSection(null); + } + : undefined + } + onDelete={(anchorEl) => { + void handleRemoveSection( + projectPath, + subProjectPath, + anchorEl + ); + }} + /> + ); - }; - - const renderTaskGroupRows = (params: { - group: SidebarTaskGroupModel; - sectionId?: string; - rowMetaByWorkspaceId: ReadonlyMap; - memberMetaByWorkspaceId: ReadonlyMap; - }): React.ReactNode[] => { - const headerMeta = params.rowMetaByWorkspaceId.get( - params.group.storageKey + } + )} + + {!flatSidebarEnabled && isExpanded && ( +
+ {(() => { + // Archived workspaces are excluded from workspaceMetadata so won't appear here + + const draftsForProject = + workspaceDraftsByProject[projectPath] ?? []; + const activeDraftIds = new Set( + draftsForProject.map((draft) => draft.draftId) ); - const headerDepth = - headerMeta?.depth ?? - depthByWorkspaceId[params.group.anchorId] ?? - (depthByWorkspaceId[params.group.parentWorkspaceId] ?? -1) + 1; - - if ( - params.group.kind === "workflow" && - params.group.hasActiveMember - ) { - sessionActiveTaskGroupKeysRef.current.add( - params.group.storageKey - ); - } - const defaultExpanded = - params.group.kind === "workflow" && - (params.group.hasActiveMember || - sessionActiveTaskGroupKeysRef.current.has( - params.group.storageKey - )); - const isExpanded = - expandedTaskGroups[params.group.storageKey] ?? defaultExpanded; - const isGroupSelected = params.group.allMembers.some( - (member) => member.id === selectedWorkspace?.workspaceId + const draftPromotionsForProject = + workspaceDraftPromotionsByProject[projectPath] ?? {}; + const activeDraftPromotions = Object.fromEntries( + Object.entries(draftPromotionsForProject).filter(([draftId]) => + activeDraftIds.has(draftId) + ) ); - - const headerRow = ( - { - toggleTaskGroupExpansion(params.group.storageKey, isExpanded); - }} - /> + const promotedWorkspaceIds = new Set( + Object.values(activeDraftPromotions).map( + (metadata) => metadata.id + ) ); - const renderedRows: React.ReactNode[] = [ - headerMeta != null ? ( - ({ - left: getAncestorRailX(trunk.depth, "default"), - active: trunk.active, - }))} - connectorRailX={getSubAgentParentRailX( - headerDepth, - "default" - )} - childStatusCenterX={getSubAgentChildStatusCenterX( - headerDepth - )} - isSelected={isGroupSelected} - isElbowActive={ - params.group.runActiveWithoutMembers === true || - params.group.runningCount > 0 - } - > - {headerRow} - - ) : ( - - {headerRow} - - ), - ]; - - if (isExpanded) { - for (const member of params.group.displayMembers) { - renderedRows.push( - renderWorkspace( - member, - params.sectionId, - params.memberMetaByWorkspaceId.get(member.id) ?? null, - getTaskGroupMemberDepth(headerDepth), - `task-group-member:${params.group.storageKey}:${member.id}`, - "task-group-member", - params.group.title - ) - ); - } + const projectRowsForDisplay = hideSubAgentRows + ? topLevelProjectWorkspaces + : projectWorkspaces; + const workspacesForNormalRendering = projectRowsForDisplay.filter( + (workspace) => !promotedWorkspaceIds.has(workspace.id) + ); + const sections: SectionConfig[] = getSubProjectsForParent( + projectPath, + userProjects + ).map(([subProjectPath, subProjectConfig]) => ({ + id: subProjectPath, + name: getProjectDisplayName(subProjectPath, subProjectConfig), + color: subProjectConfig.color, + })); + const depthByWorkspaceId = + computeWorkspaceDepthMap(projectWorkspaces); + // Track runs that are (or were, this session) active so their + // groups stay mounted across step gaps where every member is + // momentarily terminal (no flash-out between sequential steps). + for (const key of collectActiveWorkflowGroupKeys( + workspacesForNormalRendering, + { isWorkspaceLiveActive } + )) { + sessionActiveTaskGroupKeysRef.current.add(key); } - return renderedRows; - }; - - const renderWorkspaceRowsWithTaskGroupCoalescing = ({ - rows, - sectionId, - rowMetaByWorkspaceId, - taskGroups, - memberMetaByWorkspaceId, - retainedWorkflowGroupsByParentId, - }: { - rows: FrontendWorkspaceMetadata[]; - sectionId?: string; - rowMetaByWorkspaceId: ReadonlyMap; - taskGroups: SidebarTaskGroupsResult; - memberMetaByWorkspaceId: ReadonlyMap; - retainedWorkflowGroupsByParentId: ReadonlyMap< - string, - SidebarTaskGroupModel[] - >; - }): React.ReactNode[] => { - const renderedRows: React.ReactNode[] = []; - - for (const workspace of rows) { - const groupKey = - taskGroups.memberGroupStorageKeyByWorkspaceId.get(workspace.id); - const group = - groupKey != null - ? taskGroups.groupsByStorageKey.get(groupKey) - : undefined; - if (group == null) { - renderedRows.push( - renderWorkspace( - workspace, - sectionId, - rowMetaByWorkspaceId.get(workspace.id) - ) - ); - for (const retainedGroup of retainedWorkflowGroupsByParentId.get( - workspace.id - ) ?? []) { - renderedRows.push( - ...renderTaskGroupRows({ - group: retainedGroup, - sectionId, - rowMetaByWorkspaceId, - memberMetaByWorkspaceId, - }) - ); - } + const visibleWorkspacesForNormalRendering = filterVisibleAgentRows( + workspacesForNormalRendering, + expandedCompletedParentIds, + { isWorkspaceLiveActive } + ); + const baseRowMetaByWorkspaceId = computeAgentRowRenderMeta( + workspacesForNormalRendering, + depthByWorkspaceId, + expandedCompletedParentIds, + { isWorkspaceLiveActive } + ); + const sortedDrafts = draftsForProject + .slice() + .sort((a, b) => b.createdAt - a.createdAt); + const draftVisibilityForProject = + draftVisibilityByProject[projectPath] ?? {}; + const hasVisibleDrafts = sortedDrafts.some((draft) => { + const reactiveVisibility = + draftVisibilityForProject[draft.draftId]; + return ( + reactiveVisibility ?? isDraftVisible(projectPath, draft.draftId) + ); + }); + const projectHasNoAgentsOrDrafts = + projectWorkspaces.length === 0 && !hasVisibleDrafts; + const draftNumberById = new Map( + sortedDrafts.map( + (draft, index) => [draft.draftId, index + 1] as const + ) + ); + const getDraftSectionId = ( + draft: (typeof sortedDrafts)[number] + ): string | null => + typeof draft.subProjectPath === "string" && + userProjects.get(draft.subProjectPath)?.parentProjectPath === + projectPath + ? draft.subProjectPath + : null; + + // Drafts can reference a section that has since been deleted. + // Treat those as unsectioned so they remain accessible. + const unsectionedDrafts: typeof sortedDrafts = []; + const draftsBySectionId = new Map(); + for (const draft of sortedDrafts) { + const sectionId = getDraftSectionId(draft); + if (sectionId === null) { + unsectionedDrafts.push(draft); continue; } - if (group.anchorId !== workspace.id) { - // Non-anchor members render under the group header at the - // anchor's position (D5), so suppress them here. - continue; + const existing = draftsBySectionId.get(sectionId); + if (existing) { + existing.push(draft); + } else { + draftsBySectionId.set(sectionId, [draft]); } - - renderedRows.push( - ...renderTaskGroupRows({ - group, - sectionId, - rowMetaByWorkspaceId, - memberMetaByWorkspaceId, - }) - ); - } - - return renderedRows; - }; - - const renderDraft = ( - draft: (typeof sortedDrafts)[number] - ): React.ReactNode => { - const sectionId = getDraftSectionId(draft); - const promotedMetadata = activeDraftPromotions[draft.draftId]; - - if (promotedMetadata) { - const liveMetadata = - projectWorkspaces.find( - (workspace) => workspace.id === promotedMetadata.id - ) ?? promotedMetadata; - return renderWorkspace(liveMetadata, sectionId ?? undefined); } - const draftNumber = draftNumberById.get(draft.draftId) ?? 0; - const isSelected = - pendingNewWorkspaceProject === projectPath && - pendingNewWorkspaceDraftId === draft.draftId; + const renderWorkspace = ( + metadata: FrontendWorkspaceMetadata, + sectionId?: string, + rowRenderMetaOverride?: AgentRowRenderMeta | null, + depthOverride?: number, + keyOverride?: string, + subAgentConnectorLayout?: "default" | "task-group-member", + taskGroupHeaderTitle?: string + ) => { + const rowRenderMeta = + rowRenderMetaOverride === undefined + ? baseRowMetaByWorkspaceId.get(metadata.id) + : (rowRenderMetaOverride ?? undefined); - return ( - { - handleDraftVisibilityChange( - projectPath, - draft.draftId, - isVisible - ); - }} - onOpen={() => - handleOpenWorkspaceDraft(projectPath, draft.draftId) - } - onDelete={() => { - if (isSelected) { - const currentIndex = sortedDrafts.findIndex( - (d) => d.draftId === draft.draftId - ); - const fallback = - currentIndex >= 0 - ? (sortedDrafts[currentIndex + 1] ?? - sortedDrafts[currentIndex - 1]) - : undefined; - - if (fallback) { - openWorkspaceDraft(projectPath, fallback.draftId); - } else { - navigateToProject(sectionId ?? projectPath); - } + return ( + - ); - }; - - // Render age tiers for a list of workspaces - const renderAgeTiers = ( - workspaces: FrontendWorkspaceMetadata[], - tierKeyPrefix: string, - sectionId?: string, - allRowsForTaskGroupCoalescing: FrontendWorkspaceMetadata[] = workspaces - ): React.ReactNode => { - // With age grouping disabled, keep every workspace in the - // recent path (flat recency-sorted list); full-length empty - // buckets preserve tier-index assumptions below. - const { recent: topVisibleRows, buckets } = ageGroupingEnabled - ? partitionWorkspacesByAge(workspaces, workspaceRecency) - : { - recent: workspaces, - buckets: AGE_THRESHOLDS_DAYS.map( - (): FrontendWorkspaceMetadata[] => [] - ), - }; - - const expandedTierVisibleIds = new Set(); - const markExpandedTierRowsVisible = (tierIndex: number): void => { - const bucket = buckets[tierIndex]; - const remainingCount = buckets - .slice(tierIndex) - .reduce((sum, bucketRows) => sum + bucketRows.length, 0); - if (remainingCount === 0) { - return; - } - - const tierKey = `${tierKeyPrefix}:${tierIndex}`; - const isTierExpanded = expandedOldWorkspaces[tierKey] ?? false; - if (!isTierExpanded) { - return; - } - - for (const workspace of bucket) { - expandedTierVisibleIds.add(workspace.id); - } - - const nextTier = findNextNonEmptyTier(buckets, tierIndex + 1); - if (nextTier !== -1) { - markExpandedTierRowsVisible(nextTier); - } + onSelectWorkspace={handleSelectWorkspace} + onForkWorkspace={handleForkWorkspace} + onStopRuntime={handleStopRuntime} + onArchiveWorkspace={handleArchiveWorkspace} + onCancelCreation={handleCancelWorkspaceCreation} + depth={ + depthOverride ?? + rowRenderMeta?.depth ?? + depthByWorkspaceId[metadata.id] ?? + 0 + } + sectionId={sectionId} + pinnedReorderGroup={getPinnedReorderGroup( + projectPath, + sectionId + )} + onPinnedReorderDrop={handlePinnedReorderDrop} + rowRenderMeta={rowRenderMeta} + subAgentConnectorLayout={subAgentConnectorLayout} + taskGroupHeaderTitle={taskGroupHeaderTitle} + isWorkspaceLiveActive={isWorkspaceLiveActive(metadata.id)} + delegatedActivity={delegatedActivityByWorkspaceId.get( + metadata.id + )} + hiddenSubAgentsSummary={getHiddenSubAgentsSummary( + metadata.id + )} + getWorkflowRunName={getWorkflowRunName} + completedChildrenExpanded={expandedCompletedParentIds.has( + metadata.id + )} + onToggleCompletedChildren={toggleCompletedChildrenExpansion} + /> + ); }; - const firstTier = findNextNonEmptyTier(buckets, 0); - if (firstTier !== -1) { - markExpandedTierRowsVisible(firstTier); - } - - // Connector geometry should match the rows users can currently see, - // not hidden siblings parked behind collapsed age tiers. - const visibleRowIds = new Set([ - ...topVisibleRows.map((workspace) => workspace.id), - ...expandedTierVisibleIds, - ]); - const visibleRows = workspaces.filter((workspace) => - visibleRowIds.has(workspace.id) - ); - // Coalesce grouped task rows (best-of + workflow runs) - // before deriving connector geometry: headers join the row model - // as synthetic nodes so trunks/elbows stay continuous (D5). - const taskGroups = computeSidebarTaskGroups({ - rows: visibleRows, - allRows: allRowsForTaskGroupCoalescing, - selectedWorkspaceId: selectedWorkspace?.workspaceId, - isWorkspaceLiveActive, - }); + const renderDraft = ( + draft: (typeof sortedDrafts)[number] + ): React.ReactNode => { + const sectionId = getDraftSectionId(draft); + const promotedMetadata = activeDraftPromotions[draft.draftId]; - for (const group of taskGroups.groupsByStorageKey.values()) { - if ( - group.kind === "workflow" && - (group.hasActiveMember || - sessionActiveTaskGroupKeysRef.current.has(group.storageKey)) - ) { - retainedWorkflowTaskGroupsRef.current.set( - group.storageKey, - group - ); + if (promotedMetadata) { + const liveMetadata = + projectWorkspaces.find( + (workspace) => workspace.id === promotedMetadata.id + ) ?? promotedMetadata; + return renderWorkspace(liveMetadata, sectionId ?? undefined); } - } - const retainedWorkflowGroupsByParentId = new Map< - string, - SidebarTaskGroupModel[] - >(); - for (const [ - storageKey, - retainedGroup, - ] of retainedWorkflowTaskGroupsRef.current) { - if (taskGroups.groupsByStorageKey.has(storageKey)) { - continue; - } - if ( - !isWorkflowRunActive( - retainedGroup.parentWorkspaceId, - retainedGroup.id - ) - ) { - retainedWorkflowTaskGroupsRef.current.delete(storageKey); - sessionActiveTaskGroupKeysRef.current.delete(storageKey); - continue; - } - // The parent's hidden-sub-agents summary replaces retained - // workflow headers while sub-agent rows are hidden. - if (hideSubAgentRows) { - continue; - } - if (!visibleRowIds.has(retainedGroup.parentWorkspaceId)) { - continue; - } + const draftNumber = draftNumberById.get(draft.draftId) ?? 0; + const isSelected = + pendingNewWorkspaceProject === projectPath && + pendingNewWorkspaceDraftId === draft.draftId; - const groups = - retainedWorkflowGroupsByParentId.get( - retainedGroup.parentWorkspaceId - ) ?? []; - groups.push({ - ...retainedGroup, - displayMembers: [], - // The workflow run itself remains active between transient worker - // steps, without inventing a running member-task count. - runningCount: 0, - queuedCount: 0, - runActiveWithoutMembers: true, - hasActiveMember: true, - }); - retainedWorkflowGroupsByParentId.set( - retainedGroup.parentWorkspaceId, - groups + return ( + { + handleDraftVisibilityChange( + projectPath, + draft.draftId, + isVisible + ); + }} + onOpen={() => + handleOpenWorkspaceDraft(projectPath, draft.draftId) + } + onDelete={() => { + if (isSelected) { + const currentIndex = sortedDrafts.findIndex( + (d) => d.draftId === draft.draftId + ); + const fallback = + currentIndex >= 0 + ? (sortedDrafts[currentIndex + 1] ?? + sortedDrafts[currentIndex - 1]) + : undefined; + + if (fallback) { + openWorkspaceDraft(projectPath, fallback.draftId); + } else { + navigateToProject(sectionId ?? projectPath); + } + } + + deleteWorkspaceDraft(projectPath, draft.draftId); + }} + /> ); - } + }; - const rowNodes: SidebarVisibleRowNode[] = []; - const seenGroupKeys = new Set(); - for (const workspace of visibleRows) { - const groupKey = - taskGroups.memberGroupStorageKeyByWorkspaceId.get(workspace.id); - const group = - groupKey != null - ? taskGroups.groupsByStorageKey.get(groupKey) - : undefined; - if (group != null) { - if (seenGroupKeys.has(group.storageKey)) { - continue; + // Render age tiers for a list of workspaces + const renderAgeTiers = ( + workspaces: FrontendWorkspaceMetadata[], + tierKeyPrefix: string, + sectionId?: string, + allRowsForTaskGroupCoalescing: FrontendWorkspaceMetadata[] = workspaces + ): React.ReactNode => + renderCoalescedWorkspaceList( + workspaces, + allRowsForTaskGroupCoalescing, + { + sectionId, + tierKeyPrefix, + // Tier toggles align with folder-nested content. + tierButtonClassName: "pl-7", + depthByWorkspaceId, + baseRowMetaByWorkspaceId, + renderRow: (metadata, opts) => + renderWorkspace( + metadata, + opts?.sectionId, + opts?.rowRenderMeta, + opts?.depthOverride, + opts?.keyOverride, + opts?.subAgentConnectorLayout, + opts?.taskGroupHeaderTitle + ), } - seenGroupKeys.add(group.storageKey); - const anchorMeta = baseRowMetaByWorkspaceId.get(workspace.id); - const headerDepth = - anchorMeta?.depth ?? depthByWorkspaceId[workspace.id] ?? 0; - rowNodes.push({ - id: group.storageKey, - parentId: - anchorMeta?.visibleParentWorkspaceId ?? - group.parentWorkspaceId, - depth: headerDepth, - isRunning: group.runningCount > 0, - baseMeta: { - depth: headerDepth, - rowKind: "subagent", - connectorPosition: "single", - connectorStartsAtParent: false, - sharedTrunkActiveThroughRow: false, - sharedTrunkActiveBelowRow: false, - ancestorTrunks: [], - hasHiddenCompletedChildren: false, - visibleCompletedChildrenCount: 0, - }, - }); - continue; - } - - const baseRowMeta = baseRowMetaByWorkspaceId.get(workspace.id); - if (!baseRowMeta) { - continue; - } - rowNodes.push({ - id: workspace.id, - parentId: - baseRowMeta.visibleParentWorkspaceId ?? - workspace.parentWorkspaceId, - depth: baseRowMeta.depth, - isRunning: isSidebarSubAgentRunning(workspace, { - isWorkspaceLiveActive, - }), - baseMeta: baseRowMeta, - }); - for (const retainedGroup of retainedWorkflowGroupsByParentId.get( - workspace.id - ) ?? []) { - const headerDepth = baseRowMeta.depth + 1; - rowNodes.push({ - id: retainedGroup.storageKey, - parentId: workspace.id, - depth: headerDepth, - isRunning: true, - baseMeta: { - depth: headerDepth, - rowKind: "subagent", - connectorPosition: "single", - connectorStartsAtParent: false, - sharedTrunkActiveThroughRow: false, - sharedTrunkActiveBelowRow: false, - ancestorTrunks: [], - hasHiddenCompletedChildren: false, - visibleCompletedChildrenCount: 0, - }, - }); - } - } - const rowMetaByVisibleWorkspaceId = - computeRowMetaForVisibleNodes(rowNodes); - - // Expanded members hang off their header row, so their connector - // meta derives from the header's computed geometry. - const memberMetaByWorkspaceId = new Map< - string, - AgentRowRenderMeta - >(); - for (const group of taskGroups.groupsByStorageKey.values()) { - const headerMeta = rowMetaByVisibleWorkspaceId.get( - group.storageKey ); - if (headerMeta == null) { - continue; - } - for (const [ - memberId, - memberMeta, - ] of computeTaskGroupMemberRowMeta({ - group, - headerMeta, - headerDepth: headerMeta.depth, - isWorkspaceLiveActive, - })) { - memberMetaByWorkspaceId.set(memberId, memberMeta); - } - } - const renderTier = (tierIndex: number): React.ReactNode => { - const bucket = buckets[tierIndex]; - const remainingCount = buckets - .slice(tierIndex) - .reduce((sum, b) => sum + b.length, 0); + // Partition both the full section membership and the filtered visible rows. + // Best-of grouping stays leaf-only by consulting the unfiltered section data, + // while actual rendering still follows the visible hierarchy. + const { + unsectioned: allUnsectionedForNormalRendering, + bySectionId: allBySectionIdForNormalRendering, + } = partitionWorkspacesBySection( + workspacesForNormalRendering, + sections + ); + const { unsectioned, bySectionId } = partitionWorkspacesBySection( + visibleWorkspacesForNormalRendering, + sections + ); - if (remainingCount === 0) return null; + // Render section with its workspaces + const renderSection = (section: SectionConfig) => { + const sectionWorkspaces = bySectionId.get(section.id) ?? []; + const sectionAllWorkspaces = + allBySectionIdForNormalRendering.get(section.id) ?? []; + const sectionDrafts = draftsBySectionId.get(section.id) ?? []; + const sectionHasPromotedAttention = sectionDrafts.some( + (draft) => { + const promotedMetadata = activeDraftPromotions[draft.draftId]; + return promotedMetadata + ? workspaceAttentionById.get(promotedMetadata.id) === true + : false; + } + ); + const sectionHasAttention = + sectionAllWorkspaces.some( + (workspace) => + workspaceAttentionById.get(workspace.id) === true + ) || sectionHasPromotedAttention; - const tierKey = `${tierKeyPrefix}:${tierIndex}`; - const isTierExpanded = expandedOldWorkspaces[tierKey] ?? false; - const thresholdDays = AGE_THRESHOLDS_DAYS[tierIndex]; - const thresholdLabel = formatDaysThreshold(thresholdDays); - const displayCount = isTierExpanded - ? bucket.length - : remainingCount; + const sectionExpandedKey = getSectionExpandedKey( + projectPath, + section.id + ); + const isSectionExpanded = + expandedSections[sectionExpandedKey] ?? true; + const shouldAutoEditSection = + autoEditingSection?.projectPath === projectPath && + autoEditingSection?.sectionId === section.id; return ( - - - {isTierExpanded && ( - <> - {renderWorkspaceRowsWithTaskGroupCoalescing({ - rows: bucket, - sectionId, - rowMetaByWorkspaceId: rowMetaByVisibleWorkspaceId, - taskGroups, - memberMetaByWorkspaceId, - retainedWorkflowGroupsByParentId, - })} - {(() => { - const nextTier = findNextNonEmptyTier( - buckets, - tierIndex + 1 - ); - return nextTier !== -1 ? renderTier(nextTier) : null; - })()} - )} - + ); }; return ( <> - {renderWorkspaceRowsWithTaskGroupCoalescing({ - rows: topVisibleRows, - sectionId, - rowMetaByWorkspaceId: rowMetaByVisibleWorkspaceId, - taskGroups, - memberMetaByWorkspaceId, - retainedWorkflowGroupsByParentId, - })} - {firstTier !== -1 && renderTier(firstTier)} - - ); - }; - - // Partition both the full section membership and the filtered visible rows. - // Best-of grouping stays leaf-only by consulting the unfiltered section data, - // while actual rendering still follows the visible hierarchy. - const { - unsectioned: allUnsectionedForNormalRendering, - bySectionId: allBySectionIdForNormalRendering, - } = partitionWorkspacesBySection( - workspacesForNormalRendering, - sections - ); - const { unsectioned, bySectionId } = partitionWorkspacesBySection( - visibleWorkspacesForNormalRendering, - sections - ); - - // Handle workspace drop into section - const handleWorkspaceSectionDrop = ( - workspaceId: string, - targetSectionId: string | null - ) => { - void (async () => { - const result = await assignWorkspaceToSubProject( - projectPath, - workspaceId, - targetSectionId - ); - if (result.success) { - // Refresh workspace metadata so UI shows updated sectionId - await refreshWorkspaceMetadata(); - } - })(); - }; - - // Render section with its workspaces - const renderSection = (section: SectionConfig) => { - const sectionWorkspaces = bySectionId.get(section.id) ?? []; - const sectionAllWorkspaces = - allBySectionIdForNormalRendering.get(section.id) ?? []; - const sectionDrafts = draftsBySectionId.get(section.id) ?? []; - const sectionHasPromotedAttention = sectionDrafts.some((draft) => { - const promotedMetadata = activeDraftPromotions[draft.draftId]; - return promotedMetadata - ? workspaceAttentionById.get(promotedMetadata.id) === true - : false; - }); - const sectionHasAttention = - sectionAllWorkspaces.some( - (workspace) => workspaceAttentionById.get(workspace.id) === true - ) || sectionHasPromotedAttention; - - const sectionExpandedKey = getSectionExpandedKey( - projectPath, - section.id - ); - const isSectionExpanded = - expandedSections[sectionExpandedKey] ?? true; - const shouldAutoEditSection = - autoEditingSection?.projectPath === projectPath && - autoEditingSection?.sectionId === section.id; - - return ( - - toggleSection(projectPath, section.id)} - onAddWorkspace={() => { - // Create workspace in this section - handleAddWorkspace(projectPath, section.id); - }} - onRename={(name) => { - if (shouldAutoEditSection) { - setAutoEditingSection(null); - } - void updateDisplayName(section.id, name); - }} - onChangeColor={(color) => { - void updateProjectColor(section.id, color); - }} - autoStartEditing={shouldAutoEditSection} - onAutoCreateAbandon={ - shouldAutoEditSection - ? () => { - void (async () => { - setAutoEditingSection(null); - await handleRemoveSection(projectPath, section.id); - })(); - } - : undefined - } - onAutoCreateRenameCancel={ - shouldAutoEditSection - ? () => { - setAutoEditingSection(null); - } - : undefined - } - onDelete={(anchorEl) => { - void handleRemoveSection(projectPath, section.id, anchorEl); - }} - /> - {isSectionExpanded && ( -
- {sectionDrafts.map((draft) => renderDraft(draft))} - {sectionWorkspaces.length > 0 ? ( + {projectHasNoAgentsOrDrafts && ( +
+ Empty +
+ )} + {/* Unsectioned workspaces first - always show drop zone when sections exist */} + {sections.length > 0 ? ( + + {unsectionedDrafts.map((draft) => renderDraft(draft))} + {unsectioned.length > 0 ? ( renderAgeTiers( - sectionWorkspaces, - getSectionTierKey(projectPath, section.id, 0).replace( - ":tier:0", - ":tier" - ), - section.id, - sectionAllWorkspaces + unsectioned, + getTierKey(projectPath, 0).replace(":0", ""), + undefined, + allUnsectionedForNormalRendering ) - ) : sectionDrafts.length === 0 ? ( + ) : unsectionedDrafts.length === 0 ? (
- No chats in this sub-project + No unsectioned chats
) : null} -
+
+ ) : ( + <> + {unsectionedDrafts.map((draft) => renderDraft(draft))} + {unsectioned.length > 0 && + renderAgeTiers( + unsectioned, + getTierKey(projectPath, 0).replace(":0", ""), + undefined, + allUnsectionedForNormalRendering + )} + )} - + + {/* Sections */} + {sections.map(renderSection)} + ); - }; - - return ( - <> - {projectHasNoAgentsOrDrafts && ( -
- Empty -
- )} - {/* Unsectioned workspaces first - always show drop zone when sections exist */} - {sections.length > 0 ? ( - - {unsectionedDrafts.map((draft) => renderDraft(draft))} - {unsectioned.length > 0 ? ( - renderAgeTiers( - unsectioned, - getTierKey(projectPath, 0).replace(":0", ""), - undefined, - allUnsectionedForNormalRendering - ) - ) : unsectionedDrafts.length === 0 ? ( -
- No unsectioned chats -
- ) : null} -
- ) : ( - <> - {unsectionedDrafts.map((draft) => renderDraft(draft))} - {unsectioned.length > 0 && - renderAgeTiers( - unsectioned, - getTierKey(projectPath, 0).replace(":0", ""), - undefined, - allUnsectionedForNormalRendering - )} - - )} - - {/* Sections */} - {sections.map(renderSection)} - - ); - })()} -
- )} -
- ); - }) + })()} +
+ )} +
+ ); + })} + )} diff --git a/src/browser/components/SectionHeader/SectionHeader.tsx b/src/browser/components/SectionHeader/SectionHeader.tsx index 7147786dcdf..866068a3e35 100644 --- a/src/browser/components/SectionHeader/SectionHeader.tsx +++ b/src/browser/components/SectionHeader/SectionHeader.tsx @@ -26,7 +26,8 @@ interface SectionHeaderProps { isExpanded: boolean; workspaceCount: number; hasAttention: boolean; - onToggleExpand: () => void; + /** Omitted in the flat sidebar's management rows: nothing nests under them. */ + onToggleExpand?: () => void; onAddWorkspace: () => void; onRename: (name: string) => void; onChangeColor: (color: string) => void; @@ -115,30 +116,36 @@ export const SectionHeader: React.FC = ({ data-section-id={section.id} > {/* Expand/Collapse Button */} - + ) : ( + + - + )} {/* Section Name */} {isEditing ? ( diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index e4f1307c9c7..081f78561d7 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -64,7 +64,7 @@ import { } from "@/browser/utils/rightSidebarLayout"; import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { isWorkspaceArchived } from "@/common/utils/archive"; -import { reassignPinnedTimestamps } from "@/common/utils/pin"; +import { appendPinnedTimestamp, reassignPinnedTimestamps } from "@/common/utils/pin"; import { shouldApplyWorkspaceAiSettingsFromBackend } from "@/browser/utils/workspaceAiSettingsSync"; import { isAbortError } from "@/browser/utils/isAbortError"; import { findAdjacentWorkspaceId } from "@/browser/utils/ui/workspaceDomNav"; @@ -1509,36 +1509,44 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { async (workspaceId: string, pinned: boolean): Promise<{ success: boolean; error?: string }> => { if (!api) return { success: false, error: "API not connected" }; - let previousPinnedAt: string | undefined; - let applied = false; + let previousPinnedAtById: Map | null = null; setWorkspaceMetadata((prev) => { const meta = prev.get(workspaceId); if (!meta || Boolean(meta.pinnedAt) === pinned) return prev; - previousPinnedAt = meta.pinnedAt; - applied = true; - // Mirror the server's append-only ordering: strictly greater than every - // existing pin in the same project so rapid pins stay in click order. + const touched = new Map([[workspaceId, meta.pinnedAt]]); + const next = new Map(prev); + // Mirror the server's global append-only ordering (all projects, not + // just this one), including its write-path healing of corrupted pin + // timestamps, so every row sits where authoritative metadata will + // keep it. let optimisticPinnedAt: string | undefined; if (pinned) { - let pinnedAtMs = Date.now(); - for (const other of prev.values()) { - if (other.projectPath !== meta.projectPath || !other.pinnedAt) continue; - const otherMs = new Date(other.pinnedAt).getTime(); - if (Number.isFinite(otherMs) && otherMs >= pinnedAtMs) pinnedAtMs = otherMs + 1; + const { changed, pinnedAt } = appendPinnedTimestamp( + [...prev.values()] + .filter((other) => other.pinnedAt) + .map((other) => ({ id: other.id, pinnedAt: other.pinnedAt })) + ); + for (const [id, healedPinnedAt] of changed) { + const other = next.get(id); + if (!other) continue; + touched.set(id, other.pinnedAt); + next.set(id, { ...other, pinnedAt: healedPinnedAt }); } - optimisticPinnedAt = new Date(pinnedAtMs).toISOString(); + optimisticPinnedAt = pinnedAt; } - const next = new Map(prev); next.set(workspaceId, { ...meta, pinnedAt: optimisticPinnedAt }); + previousPinnedAtById = touched; return next; }); const revert = () => { - if (!applied) return; + const touched: Map | null = previousPinnedAtById; + if (!touched) return; setWorkspaceMetadata((prev) => { - const meta = prev.get(workspaceId); - if (!meta) return prev; const next = new Map(prev); - next.set(workspaceId, { ...meta, pinnedAt: previousPinnedAt }); + for (const [id, priorPinnedAt] of touched) { + const meta = next.get(id); + if (meta) next.set(id, { ...meta, pinnedAt: priorPinnedAt }); + } return next; }); }; diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index e0fa9a38cb7..991fc98023e 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { ThemeProvider } from "@/browser/contexts/ThemeContext"; import * as ActualSelectPrimitiveModule from "@/browser/components/SelectPrimitive/SelectPrimitive"; import { installDom } from "../../../../../tests/ui/dom"; -import { BASH_COLLAPSED_SUMMARY_MODE_KEY } from "@/common/constants/storage"; +import { BASH_COLLAPSED_SUMMARY_MODE_KEY, SIDEBAR_FLAT_MODE_KEY } from "@/common/constants/storage"; import { DEFAULT_CODER_ARCHIVE_BEHAVIOR, type CoderWorkspaceArchiveBehavior, @@ -319,6 +319,18 @@ describe("GeneralSection", () => { }); } + test("persists flat chat list mode from the Sidebar group", () => { + const { view } = renderGeneralSection(); + const sidebarHeading = view.getByRole("heading", { name: "Sidebar" }); + const sidebarGroup = sidebarHeading.parentElement; + expect(sidebarGroup).not.toBeNull(); + const toggle = within(sidebarGroup!).getByLabelText("Toggle flat chat list"); + + fireEvent.click(toggle); + + expect(window.localStorage.getItem(SIDEBAR_FLAT_MODE_KEY)).toBe("true"); + }); + test("persists the collapsed bash summaries display mode", async () => { const { view } = renderGeneralSection(); diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 5eaf67cb331..1bfb025c240 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -27,6 +27,7 @@ import { CHAT_TRANSCRIPT_FULL_WIDTH_KEY, DEFAULT_BASH_COLLAPSED_SUMMARY_MODE, SIDEBAR_AGE_GROUPING_KEY, + SIDEBAR_FLAT_MODE_KEY, SIDEBAR_HIDE_SUBAGENTS_KEY, TRANSCRIPT_DENSITIES, normalizeBashCollapsedSummaryMode, @@ -175,6 +176,11 @@ export function GeneralSection() { SIDEBAR_AGE_GROUPING_KEY, true ); + const [sidebarFlatMode, setSidebarFlatMode] = usePersistedState( + SIDEBAR_FLAT_MODE_KEY, + false, + { listener: true } + ); // The command palette also toggles this key, so stay subscribed to // external updates while Settings is mounted. const [sidebarHideSubAgents, setSidebarHideSubAgents] = usePersistedState( @@ -595,18 +601,23 @@ export function GeneralSection() {
+
+ +
+

Sidebar

+
-
Full-width chat transcript
+
Flat chat list
- Let messages use the full chat pane instead of the default readable column. + Show all chats in a single list with project badges instead of project folders.
@@ -639,6 +650,25 @@ export function GeneralSection() { aria-label="Toggle hiding sub-agents in the sidebar" />
+
+ + +
+

Transcript

+
+
+
+
Full-width chat transcript
+
+ Let messages use the full chat pane instead of the default readable column. +
+
+ +
@@ -691,7 +721,12 @@ export function GeneralSection() {
+
+
+
+

Terminal

+
Terminal Font
@@ -838,140 +873,146 @@ export function GeneralSection() {
-

Workspace insights

-
-
-
-
API Debug Logs
-
- Record the full input and output of every AI API call +

Archiving

+
+
+
+
Coder workspace on archive
+
+ Action to take on dedicated Coder workspaces when archiving a chat. Delete is + permanent.
- +
-
-
-
-
-
Editor
-
Editor to open files in
+
+
+
Worktree archive behavior
+
+ Control whether archived xum-managed worktrees stay on disk, are deleted, or are + snapshotted so they can be restored on unarchive. +
+
+ +
-
- {editorConfig.editor === "custom" && ( -
+
+

Editor & debugging

+
-
Custom Command
-
Command to run (path will be appended)
+
Editor
+
Editor to open files in
- ) => - handleCustomCommandChange(e.target.value) - } - placeholder="e.g., nvim" - className="border-border-medium bg-background-secondary h-9 w-40" - /> +
- {isBrowserMode && ( -
- Custom editors are not supported in browser mode. Use VS Code or Cursor instead. + + {editorConfig.editor === "custom" && ( +
+
+
+
Custom Command
+
Command to run (path will be appended)
+
+ ) => + handleCustomCommandChange(e.target.value) + } + placeholder="e.g., nvim" + className="border-border-medium bg-background-secondary h-9 w-40" + /> +
+ {isBrowserMode && ( +
+ Custom editors are not supported in browser mode. Use VS Code or Cursor instead. +
+ )}
)} -
- )} -
-
-
Coder workspace on archive
-
- Action to take on dedicated Coder workspaces when archiving a chat. Delete is permanent. -
-
- -
- -
-
-
Worktree archive behavior
-
- Control whether archived xum-managed worktrees stay on disk, are deleted, or are - snapshotted so they can be restored on unarchive. +
+
+
API Debug Logs
+
+ Record the full input and output of every AI API call +
+
+
-
- -
- {isBrowserMode && sshHostLoaded && ( -
-
-
SSH Host
-
- SSH hostname for 'Open in Editor' deep links + {isBrowserMode && sshHostLoaded && ( +
+
+
SSH Host
+
+ SSH hostname for 'Open in Editor' deep links +
+
+ ) => + handleSshHostChange(e.target.value) + } + placeholder={window.location.hostname} + className="border-border-medium bg-background-secondary h-9 w-40" + />
-
- ) => - handleSshHostChange(e.target.value) - } - placeholder={window.location.hostname} - className="border-border-medium bg-background-secondary h-9 w-40" - /> + )}
- )} +

Projects

diff --git a/src/browser/stories/meta.tsx b/src/browser/stories/meta.tsx index 7fd38cd71c9..fc94ab92460 100644 --- a/src/browser/stories/meta.tsx +++ b/src/browser/stories/meta.tsx @@ -17,6 +17,7 @@ import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { SELECTED_WORKSPACE_KEY, SIDEBAR_AGE_GROUPING_KEY, + SIDEBAR_FLAT_MODE_KEY, TERMINAL_BADGE_CONFIG_KEY, UI_THEME_KEY, } from "@/common/constants/storage"; @@ -91,6 +92,10 @@ function resetStorybookPersistedStateForStory(): void { // Stories that disable sidebar age grouping must not leak the setting // into later stories via the shared localStorage origin. localStorage.removeItem(SIDEBAR_AGE_GROUPING_KEY); + // The flat chat list story persists sidebarFlatMode; clear it via the + // persisted-state helper so a mounted sidebar's subscribed snapshot + // observes the reset instead of keeping the flat layout. + updatePersistedState(SIDEBAR_FLAT_MODE_KEY, undefined); // Terminal badge stories seed an enabled badge config; clear it so other // stories with terminals don't render order-dependent badge overlays. localStorage.removeItem(TERMINAL_BADGE_CONFIG_KEY); diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index a17311df903..81ff199ad92 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -41,6 +41,7 @@ export const CommandIds = { navPrev: () => "nav:prev" as const, navToggleSidebar: () => "nav:toggleSidebar" as const, navToggleHideSubAgents: () => "nav:toggle-hide-subagents" as const, + navToggleFlatChatList: () => "nav:toggle-flat-chat-list" as const, navToggleTerminalBadge: () => "nav:toggle-terminal-badge" as const, navRightSidebarFocusTerminal: () => "nav:rightSidebar:focusTerminal" as const, navRightSidebarSplitHorizontal: () => "nav:rightSidebar:splitHorizontal" as const, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 313d96f58a8..b6ae2b3d289 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -4,7 +4,11 @@ import type { ProjectConfig } from "@/node/config"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { GlobalWindow } from "happy-dom"; -import { getModelKey, SIDEBAR_HIDE_SUBAGENTS_KEY } from "@/common/constants/storage"; +import { + getModelKey, + SIDEBAR_FLAT_MODE_KEY, + SIDEBAR_HIDE_SUBAGENTS_KEY, +} from "@/common/constants/storage"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import type { WorkspaceState } from "@/browser/stores/WorkspaceStore"; import type { APIClient } from "@/browser/contexts/API"; @@ -1330,6 +1334,33 @@ test("workspace generate title command dispatches a title-generation request eve } }); +test("toggle flat chat list command flips the persisted sidebar setting", () => { + const testWindow = new GlobalWindow(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + globalThis.window = testWindow as unknown as Window & typeof globalThis; + globalThis.document = testWindow.document as unknown as Document; + + try { + const toggle = () => { + const action = getActions().find((a) => a.id === "nav:toggle-flat-chat-list"); + expect(action).toBeDefined(); + void action!.run(); + }; + + toggle(); + expect(window.localStorage.getItem(SIDEBAR_FLAT_MODE_KEY)).toBe("true"); + expect(getActions().find((a) => a.id === "nav:toggle-flat-chat-list")?.subtitle).toContain( + "Flat" + ); + toggle(); + expect(window.localStorage.getItem(SIDEBAR_FLAT_MODE_KEY)).toBe("false"); + } finally { + globalThis.window = originalWindow; + globalThis.document = originalDocument; + } +}); + test("toggle hide sub-agents command flips the persisted sidebar setting", () => { const testWindow = new GlobalWindow(); const originalWindow = globalThis.window; diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 94fc01a220c..fba9c4fb94e 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -27,6 +27,7 @@ import { DEFAULT_TERMINAL_BADGE_CONFIG, RIGHT_SIDEBAR_COLLAPSED_KEY, SIDEBAR_HIDE_SUBAGENTS_KEY, + SIDEBAR_FLAT_MODE_KEY, TERMINAL_BADGE_CONFIG_KEY, normalizeTerminalBadgeConfig, type TerminalBadgeConfig, @@ -721,6 +722,16 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi updatePersistedState(SIDEBAR_HIDE_SUBAGENTS_KEY, (prev) => !prev, false); }, }, + { + id: CommandIds.navToggleFlatChatList(), + title: "Toggle Flat Chat List", + subtitle: `Current: ${readPersistedState(SIDEBAR_FLAT_MODE_KEY, false) ? "Flat" : "Grouped"}`, + section: section.navigation, + keywords: ["flat", "chat", "list", "projects", "folders", "sidebar"], + run: () => { + updatePersistedState(SIDEBAR_FLAT_MODE_KEY, (prev) => !prev, false); + }, + }, { id: CommandIds.navToggleTerminalBadge(), title: "Toggle Terminal Badge", diff --git a/src/browser/utils/ui/pinnedReorder.test.ts b/src/browser/utils/ui/pinnedReorder.test.ts index 309297a7a90..df4c012d1cb 100644 --- a/src/browser/utils/ui/pinnedReorder.test.ts +++ b/src/browser/utils/ui/pinnedReorder.test.ts @@ -139,6 +139,61 @@ describe("locatePinnedBlock", () => { expect(block).toEqual({ fullOrder: ["mB", "mA"], blockIds: ["mB", "mA"] }); }); + it("treats pinned roots from every project as one block in flat mode", () => { + const a = createWorkspace("a", { + pinnedAt: "2026-01-01T00:00:01.000Z", + projectPath: "/test/a", + }); + const b = createWorkspace("b", { + pinnedAt: "2026-01-01T00:00:00.000Z", + projectPath: "/test/b", + }); + const sorted = new Map([ + ["/test/a", [a]], + ["/test/b", [b]], + ]); + + expect(locatePinnedBlock(a, sorted, new Map(), { multiProjectEnabled: true })).toEqual({ + fullOrder: ["b", "a"], + blockIds: ["b", "a"], + }); + }); + + it("excludes feature-gated multi-project pins from the flat block", () => { + // With the multi-project experiment off the sidebar hides these rows, so + // a move must swap with the next visible pin instead of an invisible row. + const a = createWorkspace("a", { + pinnedAt: "2026-01-01T00:00:00.000Z", + projectPath: "/test/a", + }); + const hiddenMulti = createWorkspace("m", { + pinnedAt: "2026-01-01T00:00:01.000Z", + projectPath: "/test/a", + projects: [ + { projectPath: "/test/a", projectName: "a" }, + { projectPath: "/test/b", projectName: "b" }, + ], + }); + const b = createWorkspace("b", { + pinnedAt: "2026-01-01T00:00:02.000Z", + projectPath: "/test/b", + }); + const sorted = new Map([ + ["/test/a", [a, hiddenMulti]], + ["/test/b", [b]], + ]); + + expect(locatePinnedBlock(a, sorted, new Map(), { multiProjectEnabled: false })).toEqual({ + fullOrder: ["a", "b"], + blockIds: ["a", "b"], + }); + // With the experiment on, the multi-project pin joins the block again. + expect(locatePinnedBlock(a, sorted, new Map(), { multiProjectEnabled: true })).toEqual({ + fullOrder: ["a", "m", "b"], + blockIds: ["a", "m", "b"], + }); + }); + it("treats all pinned scratch rows as one block despite distinct workdir projectPaths", () => { // Each scratch chat's projectPath is its own app-managed workdir, but the // sidebar renders them together in the Chats section, so a reorder between diff --git a/src/browser/utils/ui/pinnedReorder.ts b/src/browser/utils/ui/pinnedReorder.ts index 74e4e47dc33..869cd38feaf 100644 --- a/src/browser/utils/ui/pinnedReorder.ts +++ b/src/browser/utils/ui/pinnedReorder.ts @@ -13,6 +13,16 @@ import { export type PinnedMoveDirection = "up" | "down"; export type PinnedDropEdge = "before" | "after"; +/** + * Flat-mode block options, mirroring the sidebar's render gates so the + * reorder block never contains rows the user cannot see (a move would + * otherwise swap with an invisible row and appear to do nothing). + */ +export interface FlatPinnedBlockOptions { + /** The multi-project experiment gate applied to the flat chat list. */ + multiProjectEnabled: boolean; +} + /** * A workspace's pinned surroundings, both in displayed order: * - `fullOrder`: every pinned id of its config bucket. Reorder requests always @@ -50,16 +60,29 @@ function collectFlatSectionRows( /** * Resolve the pinned block containing `meta`, mirroring the sidebar renderer: * multi-project rows form one flat block; regular rows partition by their - * effective section. Returns null when the workspace is not a rendered pinned - * row (unpinned, or missing from the sorted map). + * effective section. In flat sidebar mode all pinned roots instead form one + * unified block across projects. Returns null when the workspace is not a + * rendered pinned row (unpinned, or missing from the sorted map). */ export function locatePinnedBlock( meta: FrontendWorkspaceMetadata, sortedWorkspacesByProject: Map, - userProjects: Map + userProjects: Map, + flatMode: FlatPinnedBlockOptions | false = false ): PinnedBlock | null { if (!isWorkspacePinned(meta)) return null; + if (flatMode !== false) { + const pinnedIds = collectFlatSectionRows( + sortedWorkspacesByProject, + (row) => flatMode.multiProjectEnabled || !isMultiProject(row) + ) + .filter((row) => row.parentWorkspaceId == null && isWorkspacePinned(row)) + .map((row) => row.id); + if (!pinnedIds.includes(meta.id)) return null; + return { fullOrder: pinnedIds, blockIds: pinnedIds }; + } + // Scratch chats render as one flat "Chats" section, but each row's // projectPath is its own app-managed workdir, so the per-projectPath // partitioning below would isolate every row into a block of one and @@ -130,9 +153,10 @@ export function computePinnedMoveOrderForWorkspace( meta: FrontendWorkspaceMetadata, direction: PinnedMoveDirection, sortedWorkspacesByProject: Map, - userProjects: Map + userProjects: Map, + flatMode: FlatPinnedBlockOptions | false = false ): string[] | null { - const block = locatePinnedBlock(meta, sortedWorkspacesByProject, userProjects); + const block = locatePinnedBlock(meta, sortedWorkspacesByProject, userProjects, flatMode); if (!block) return null; return computePinnedMoveOrder(block, meta.id, direction); } diff --git a/src/browser/utils/ui/workspaceFiltering.test.ts b/src/browser/utils/ui/workspaceFiltering.test.ts index 3061aac164a..5a0a91433f6 100644 --- a/src/browser/utils/ui/workspaceFiltering.test.ts +++ b/src/browser/utils/ui/workspaceFiltering.test.ts @@ -4,6 +4,7 @@ import { formatDaysThreshold, AGE_THRESHOLDS_DAYS, buildSortedWorkspacesByProject, + buildSortedWorkspacesFlat, orderMultiProjectSectionRows, computeWorkspaceDepthMap, computeAgentRowRenderMeta, @@ -628,6 +629,56 @@ describe("buildSortedWorkspacesByProject", () => { }); }); +describe("buildSortedWorkspacesFlat", () => { + it("sorts all workspace kinds globally and keeps children under their parent", () => { + const parent = { + ...createWorkspace("parent", "/project/a"), + projects: [ + { projectPath: "/project/a", projectName: "a" }, + { projectPath: "/project/b", projectName: "b" }, + ], + }; + const metadata = new Map([ + [ + "pinned-late", + { + ...createWorkspace("pinned-late", "/project/a"), + pinnedAt: "2026-01-02T00:00:00.000Z", + }, + ], + [ + "pinned-early", + { + ...createWorkspace("pinned-early", "/project/b"), + pinnedAt: "2026-01-01T00:00:00.000Z", + }, + ], + ["parent", parent], + [ + "child", + createWorkspace("child", { projectPath: "/project/a", parentWorkspaceId: "parent" }), + ], + ["scratch", { ...createWorkspace("scratch", "/scratch/path"), kind: "scratch" }], + ["recent", createWorkspace("recent", "/project/b")], + ]); + + const result = buildSortedWorkspacesFlat([...metadata.values()], { + parent: 300, + child: 1, + scratch: 200, + recent: 100, + }); + + expect(result.map((workspace) => workspace.id)).toEqual([ + "pinned-early", + "pinned-late", + "parent", + "child", + "scratch", + "recent", + ]); + }); +}); describe("buildSortedWorkspacesByProject pinning", () => { const now = Date.now(); const projectsWithIds = (ids: string[]): Map => diff --git a/src/browser/utils/ui/workspaceFiltering.ts b/src/browser/utils/ui/workspaceFiltering.ts index 38805dd1fb0..29314bcbbe3 100644 --- a/src/browser/utils/ui/workspaceFiltering.ts +++ b/src/browser/utils/ui/workspaceFiltering.ts @@ -932,6 +932,37 @@ function comparePinnedPlacement( return null; } +function sortWorkspaceRows( + workspaces: FrontendWorkspaceMetadata[], + workspaceRecency: Record +): void { + workspaces.sort((a, b) => { + const pinnedPlacement = comparePinnedPlacement(a, b); + if (pinnedPlacement !== null) { + return pinnedPlacement; + } + + const aTimestamp = workspaceRecency[a.id] ?? 0; + const bTimestamp = workspaceRecency[b.id] ?? 0; + if (aTimestamp !== bTimestamp) { + return bTimestamp - aTimestamp; + } + + const aCreatedAt = parseTimestampMs(a.createdAt); + const bCreatedAt = parseTimestampMs(b.createdAt); + if (aCreatedAt !== bCreatedAt) { + return bCreatedAt - aCreatedAt; + } + + const nameOrder = compareStringsAsc(a.name, b.name); + if (nameOrder !== 0) { + return nameOrder; + } + + return compareStringsAsc(a.id, b.id); + }); +} + /** * Build a map of project paths to sorted workspace metadata lists. * Includes both persisted workspaces (from config) and workspaces from @@ -976,31 +1007,7 @@ export function buildSortedWorkspacesByProject( // IMPORTANT: Include deterministic tie-breakers so Storybook visual snapshots can't // flip ordering when multiple workspaces have equal recency. for (const metadataList of result.values()) { - metadataList.sort((a, b) => { - const pinnedPlacement = comparePinnedPlacement(a, b); - if (pinnedPlacement !== null) { - return pinnedPlacement; - } - - const aTimestamp = workspaceRecency[a.id] ?? 0; - const bTimestamp = workspaceRecency[b.id] ?? 0; - if (aTimestamp !== bTimestamp) { - return bTimestamp - aTimestamp; - } - - const aCreatedAt = parseTimestampMs(a.createdAt); - const bCreatedAt = parseTimestampMs(b.createdAt); - if (aCreatedAt !== bCreatedAt) { - return bCreatedAt - aCreatedAt; - } - - const nameOrder = compareStringsAsc(a.name, b.name); - if (nameOrder !== 0) { - return nameOrder; - } - - return compareStringsAsc(a.id, b.id); - }); + sortWorkspaceRows(metadataList, workspaceRecency); } // Ensure child workspaces appear directly below their parents. @@ -1011,6 +1018,16 @@ export function buildSortedWorkspacesByProject( return result; } +/** Build one globally sorted workspace tree for the optional flat sidebar. */ +export function buildSortedWorkspacesFlat( + workspaces: FrontendWorkspaceMetadata[], + workspaceRecency: Record +): FrontendWorkspaceMetadata[] { + const rows = [...workspaces]; + sortWorkspaceRows(rows, workspaceRecency); + return flattenWorkspaceTree(rows); +} + /** * Order rows for the flat Multi-Project section. The rows are collected across * per-primary-project buckets of the sorted map, so without this pass two diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index c564da3d8a5..57687b6fe6c 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -743,6 +743,12 @@ export const LEFT_SIDEBAR_COLLAPSED_KEY = "sidebarCollapsed"; */ export const SIDEBAR_AGE_GROUPING_KEY = "sidebarAgeGrouping"; +/** + * When true, show all sidebar chats in one list instead of project folders. + * Format: "sidebarFlatMode" (boolean, default false) + */ +export const SIDEBAR_FLAT_MODE_KEY = "sidebarFlatMode"; + /** * Hide sub-agent rows in the left sidebar and summarize their activity on * parent rows instead. diff --git a/src/common/utils/pin.test.ts b/src/common/utils/pin.test.ts index 998c590c761..5ccf866d81d 100644 --- a/src/common/utils/pin.test.ts +++ b/src/common/utils/pin.test.ts @@ -1,8 +1,113 @@ import { describe, expect, it } from "bun:test"; -import { comparePinnedOrder, reassignPinnedTimestamps, recomposePinnedOrder } from "./pin"; +import { + appendPinnedTimestamp, + comparePinnedOrder, + nextMonotonicPinnedAtIso, + reassignPinnedTimestamps, + recomposePinnedOrder, +} from "./pin"; + +describe("nextMonotonicPinnedAtIso", () => { + it("appends strictly after the latest existing pin across all values", () => { + const iso = nextMonotonicPinnedAtIso( + ["2026-01-01T00:00:00.000Z", "2026-01-03T00:00:00.000Z", undefined], + Date.parse("2026-01-02T00:00:00.000Z") + ); + expect(iso).toBe("2026-01-03T00:00:00.001Z"); + }); + + it("uses the current time when it already exceeds every pin", () => { + const iso = nextMonotonicPinnedAtIso( + ["2026-01-01T00:00:00.000Z"], + Date.parse("2026-02-01T00:00:00.000Z") + ); + expect(iso).toBe("2026-02-01T00:00:00.000Z"); + }); + + it("clamps generated successors so they stay sortable and scannable at the boundary", () => { + const saneMax = new Date(8_640_000_000_000_000 - 1).toISOString(); + const generated = nextMonotonicPinnedAtIso([saneMax], Date.parse("2026-01-01T00:00:00.000Z")); + // Clamped to the sane maximum rather than escaping into a value that + // ordering and later scans would classify as corrupted. + expect(generated).toBe(saneMax); + expect(nextMonotonicPinnedAtIso([generated], Date.parse("2026-01-01T00:00:00.000Z"))).toBe( + saneMax + ); + const rows = [ + { id: "clamped", pinnedAt: generated }, + { id: "normal", pinnedAt: "2026-01-01T00:00:00.000Z" }, + ]; + expect(rows.sort(comparePinnedOrder).map((row) => row.id)).toEqual(["normal", "clamped"]); + }); + + it("ignores corrupted timestamps whose +1ms successor is unrepresentable", () => { + const iso = nextMonotonicPinnedAtIso( + ["+275760-09-13T00:00:00.000Z", "2026-01-03T00:00:00.000Z"], + Date.parse("2026-01-02T00:00:00.000Z") + ); + expect(iso).toBe("2026-01-03T00:00:00.001Z"); + }); +}); + +describe("appendPinnedTimestamp", () => { + it("mints max+1ms without touching existing pins", () => { + const { changed, pinnedAt } = appendPinnedTimestamp( + [{ id: "a", pinnedAt: "2026-01-03T00:00:00.000Z" }], + Date.parse("2026-01-01T00:00:00.000Z") + ); + expect(changed.size).toBe(0); + expect(pinnedAt).toBe("2026-01-03T00:00:00.001Z"); + }); + + it("detects capped collisions by parsed value, not string equality", () => { + const capMs = 8_640_000_000_000_000 - 1; + // Noncanonical representation of the cap (offset form instead of Z). + const noncanonicalCap = "+275760-09-12T23:59:59.999+00:00"; + expect(new Date(noncanonicalCap).getTime()).toBe(capMs); + const nowMs = Date.parse("2026-01-01T00:00:00.000Z"); + const { changed, pinnedAt } = appendPinnedTimestamp( + [{ id: "capped", pinnedAt: noncanonicalCap }], + nowMs + ); + expect(pinnedAt).toBe(new Date(nowMs).toISOString()); + expect(changed.get("capped")).toBe(new Date(nowMs - 1).toISOString()); + }); + + it("renumbers all pins when the sane key range saturates, keeping keys unique and ordered", () => { + const saneMax = new Date(8_640_000_000_000_000 - 1).toISOString(); + const nowMs = Date.parse("2026-01-01T00:00:00.000Z"); + const { changed, pinnedAt } = appendPinnedTimestamp( + [ + { id: "old", pinnedAt: "2026-01-01T00:00:00.000Z" }, + { id: "capped", pinnedAt: saneMax }, + ], + nowMs + ); + // The new pin gets a strictly greatest unique key and existing pins are + // renumbered below it in their current visual order. + expect(pinnedAt).toBe(new Date(nowMs).toISOString()); + expect(changed.get("old")).toBe(new Date(nowMs - 2).toISOString()); + expect(changed.get("capped")).toBe(new Date(nowMs - 1).toISOString()); + const keys = [changed.get("old"), changed.get("capped"), pinnedAt]; + expect(new Set(keys).size).toBe(3); + }); +}); describe("comparePinnedOrder", () => { + it("sorts corrupted boundary timestamps first so new pins still append last", () => { + const rows = [ + { id: "new-pin", pinnedAt: "2026-01-02T00:00:00.000Z" }, + { id: "corrupt", pinnedAt: "+275760-09-13T00:00:00.000Z" }, + { id: "old-pin", pinnedAt: "2026-01-01T00:00:00.000Z" }, + ]; + expect(rows.sort(comparePinnedOrder).map((row) => row.id)).toEqual([ + "corrupt", + "old-pin", + "new-pin", + ]); + }); + it("sorts by pinnedAt ascending with id tie-break", () => { const rows = [ { id: "b", pinnedAt: "2026-01-01T00:00:02.000Z" }, @@ -22,6 +127,35 @@ describe("comparePinnedOrder", () => { }); describe("reassignPinnedTimestamps", () => { + it("compacts capped ties below the sane maximum so reorders still apply", () => { + const capMs = 8_640_000_000_000_000 - 1; + const capIso = new Date(capMs).toISOString(); + const changed = reassignPinnedTimestamps( + ["b", "a"], + new Map([ + ["a", capIso], + ["b", capIso], + ]) + ); + // Both share the cap, so the id tie-break renders a before b; requesting + // b first must yield strictly increasing unique keys within the cap. + expect(changed.get("b")).toBe(new Date(capMs - 1).toISOString()); + expect(changed.has("a")).toBe(false); + }); + + it("re-deals corrupted boundary timestamps instead of overflowing the Date range", () => { + const boundary = "+275760-09-13T00:00:00.000Z"; + const changed = reassignPinnedTimestamps( + ["a", "b"], + new Map([ + ["a", boundary], + ["b", boundary], + ]) + ); + expect(changed.get("a")).toBe("1970-01-01T00:00:00.000Z"); + expect(changed.get("b")).toBe("1970-01-01T00:00:00.001Z"); + }); + const iso = (ms: number) => new Date(ms).toISOString(); it("re-deals the existing pool onto the new order and reports only changed entries", () => { diff --git a/src/common/utils/pin.ts b/src/common/utils/pin.ts index 5e276c5ba61..cb08f2f594d 100644 --- a/src/common/utils/pin.ts +++ b/src/common/utils/pin.ts @@ -34,10 +34,102 @@ export function isWorkspacePinnable(workspace: { return !isWorkspaceArchived(workspace.archivedAt, workspace.unarchivedAt); } -/** Unparseable/missing pinnedAt sorts first (same fallback the sidebar sort always used). */ +/** + * Unparseable/missing pinnedAt sorts first (same fallback the sidebar sort + * always used). Corrupted boundary timestamps get the same treatment so the + * comparator, the reorder re-deal, and the successor scan agree: the corrupted + * row sits stably at the top of the pinned block while new pins keep appending + * at the bottom, and any reorder re-deals it to a sane value. + */ function parsePinnedAtMs(pinnedAt: string | undefined): number { - const ms = Date.parse(pinnedAt ?? ""); - return Number.isFinite(ms) ? ms : 0; + return pinnedAtMsForSuccessorScan(pinnedAt) ?? 0; +} + +/** + * Highest sane pinnedAt epoch ms: one below the maximum representable Date so + * every accepted value still has a serializable +1ms successor. pinnedAt is + * persisted as an unrestricted string, so a corrupted-but-parseable boundary + * timestamp (e.g. "+275760-09-13T00:00:00.000Z") would otherwise poison every + * monotonic successor computation: max + 1ms leaves the representable range + * and toISOString() throws, permanently blocking pinning. Ordering and + * successor scans treat values above this as absent (self-healing), and + * generated timestamps clamp to it so they stay valid for later scans; ties at + * the clamp fall back to the comparator's id tie-break. + */ +const MAX_PINNED_AT_MS = 8_640_000_000_000_000 - 1; + +/** Epoch ms of a sane pinnedAt (parseable, within MAX_PINNED_AT_MS), else null. */ +function pinnedAtMsForSuccessorScan(pinnedAt: string | undefined): number | null { + if (!pinnedAt) return null; + const ms = new Date(pinnedAt).getTime(); + return Number.isFinite(ms) && ms <= MAX_PINNED_AT_MS ? ms : null; +} + +/** + * Monotonic pin timestamp: strictly greater than every existing + * (representable) pin so rapid pins always append deterministically, even if + * the wall clock is skewed or several pins land within the same millisecond. + * The scan is global (all projects), keeping the flat sidebar's unified pinned + * block appending at the bottom. Shared by the backend and the client's + * optimistic update so the optimistic row lands where the authoritative + * metadata will place it. + */ +export function nextMonotonicPinnedAtIso( + existingPinnedAts: Iterable, + nowMs: number = Date.now() +): string { + let pinnedAtMs = Math.min(nowMs, MAX_PINNED_AT_MS); + for (const value of existingPinnedAts) { + const ms = pinnedAtMsForSuccessorScan(value); + if (ms !== null && ms >= pinnedAtMs) pinnedAtMs = Math.min(ms + 1, MAX_PINNED_AT_MS); + } + return new Date(pinnedAtMs).toISOString(); +} + +/** + * Ordering key for a newly pinned chat plus any write-path healing. Normally + * mints max+1ms (see nextMonotonicPinnedAtIso) and changes nothing else. When + * the successor saturates at the sane cap (reachable only through corrupted + * persisted state), no strictly-greater sane key exists, so every currently + * pinned entry is renumbered compactly below nowMs in its current visual + * order: ordering keys stay unique, append order survives, and future pins + * regain their full headroom (write-path self-healing). + */ +export function appendPinnedTimestamp( + pinned: ReadonlyArray<{ id?: string; pinnedAt?: string }>, + nowMs: number = Date.now() +): { changed: Map; pinnedAt: string } { + const pinnedAt = nextMonotonicPinnedAtIso( + pinned.map((entry) => entry.pinnedAt), + nowMs + ); + // Collision detection compares parsed values: JavaScript accepts multiple + // string representations of the same capped millisecond, so raw string + // equality would miss noncanonical duplicates. + const pinnedAtMs = new Date(pinnedAt).getTime(); + if (!pinned.some((entry) => pinnedAtMsForSuccessorScan(entry.pinnedAt) === pinnedAtMs)) { + return { changed: new Map(), pinnedAt }; + } + const order = pinned.filter((entry) => entry.pinnedAt).sort(comparePinnedOrderLoose); + const changed = new Map(); + order.forEach((entry, index) => { + const iso = new Date(nowMs - order.length + index).toISOString(); + if (entry.id !== undefined && entry.pinnedAt !== iso) { + changed.set(entry.id, iso); + } + }); + return { changed, pinnedAt: new Date(nowMs).toISOString() }; +} + +/** comparePinnedOrder for entries whose id may be missing (config rows). */ +function comparePinnedOrderLoose( + a: { id?: string; pinnedAt?: string }, + b: { id?: string; pinnedAt?: string } +): number { + return comparePinnedOrder( + { id: a.id ?? "", pinnedAt: a.pinnedAt }, + { id: b.id ?? "", pinnedAt: b.pinnedAt } + ); } /** @@ -75,16 +167,30 @@ export function reassignPinnedTimestamps( orderedIds: readonly string[], currentPinnedAtById: ReadonlyMap ): Map { + // Corrupted boundary timestamps re-deal from 0 like unparseable ones so the + // +1ms nudges below can never leave the representable Date range. const poolMs = orderedIds .map((id) => parsePinnedAtMs(currentPinnedAtById.get(id))) .sort((a, b) => a - b); - const changed = new Map(); + const assignedMs: number[] = []; let previousMs = Number.NEGATIVE_INFINITY; + for (let index = 0; index < orderedIds.length; index++) { + previousMs = Math.max(poolMs[index], previousMs + 1); + assignedMs.push(previousMs); + } + // Backward clamp: capped values compact strictly below the sane maximum so + // the assigned sequence stays strictly monotonic (unique keys) and the + // requested order always persists, even when corrupted pool values tie at + // the cap. + for (let index = assignedMs.length - 1; index >= 0; index--) { + const bound = index === assignedMs.length - 1 ? MAX_PINNED_AT_MS : assignedMs[index + 1] - 1; + if (assignedMs[index] > bound) assignedMs[index] = bound; + } + + const changed = new Map(); orderedIds.forEach((id, index) => { - const ms = Math.max(poolMs[index], previousMs + 1); - previousMs = ms; - const iso = new Date(ms).toISOString(); + const iso = new Date(assignedMs[index]).toISOString(); if (currentPinnedAtById.get(id) !== iso) { changed.set(id, iso); } diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index ec69f044d2d..e2b10dc36b3 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -13338,6 +13338,44 @@ describe("WorkspaceService setPinned", () => { expect(emittedMetadata[1].metadata?.pinnedAt).toBeUndefined(); }); + test("corrupted boundary pinnedAt on another chat cannot block pinning", async () => { + // A parseable boundary timestamp has no representable +1ms successor; the + // global monotonic scan must ignore it rather than fail every future pin. + const other = getEntry(otherRootId); + if (!other) throw new Error("fixture missing otherRootId"); + other.pinnedAt = "+275760-09-13T00:00:00.000Z"; + + const result = await workspaceService.setPinned(rootId, true); + expect(result.success).toBe(true); + const pinnedAt = getEntry(rootId)?.pinnedAt; + expect(pinnedAt).toBeDefined(); + // The assigned timestamp is a normal near-now value, not a successor of + // the corrupted boundary. + expect(new Date(pinnedAt ?? "").getTime()).toBeLessThan(Date.now() + 60_000); + }); + + test("pinning heals a saturated boundary timestamp so keys stay unique", async () => { + // An existing pin at the sane cap has no strictly-greater sane successor; + // the write path renumbers pins instead of minting a duplicate key. + const saneMax = new Date(8_640_000_000_000_000 - 1).toISOString(); + const other = getEntry(otherRootId); + if (!other) throw new Error("fixture missing otherRootId"); + other.pinnedAt = saneMax; + + const result = await workspaceService.setPinned(rootId, true); + expect(result.success).toBe(true); + const rootPinnedAt = getEntry(rootId)?.pinnedAt; + const otherPinnedAt = getEntry(otherRootId)?.pinnedAt; + expect(rootPinnedAt).toBeDefined(); + expect(otherPinnedAt).toBeDefined(); + expect(rootPinnedAt).not.toBe(otherPinnedAt); + // The healed pin sorts before the new pin and both are near-now values. + expect(new Date(otherPinnedAt ?? "").getTime()).toBeLessThan( + new Date(rootPinnedAt ?? "").getTime() + ); + expect(new Date(rootPinnedAt ?? "").getTime()).toBeLessThan(Date.now() + 60_000); + }); + test("pin-when-pinned and unpin-when-unpinned are no-ops without event churn", async () => { const first = await workspaceService.setPinned(rootId, true); expect(first.success).toBe(true); @@ -13556,9 +13594,10 @@ describe("WorkspaceService reorderPinned", () => { expect(emittedMetadata).toHaveLength(0); }); - test("drops stale/unpinned/duplicate ids and appends omitted pins in current order", async () => { + test("drops stale/unpinned/duplicate ids and keeps omitted pins in place", async () => { // Client sends duplicates, an unpinned id, a sub-agent, an archived chat, - // and a ghost id, and omits B and C entirely. + // and a ghost id, and omits B entirely: C and A swap within the slots + // they occupy while omitted B keeps its position. const result = await workspaceService.reorderPinned([ idC, idC, @@ -13566,10 +13605,10 @@ describe("WorkspaceService reorderPinned", () => { childId, archivedId, "ws-ghost", + idA, ]); expect(result.success).toBe(true); - // C first, then omitted pins A, B keep their relative order. - expect(pinnedOrder()).toEqual([idC, idA, idB]); + expect(pinnedOrder()).toEqual([idC, idB, idA]); // Ineligible ids never gain pinnedAt. expect(getEntry(unpinnedId)?.pinnedAt).toBeUndefined(); expect(getEntry(childId)?.pinnedAt).toBeUndefined(); @@ -13614,6 +13653,153 @@ describe("WorkspaceService reorderPinned", () => { }); }); +describe("WorkspaceService reorderPinned across projects", () => { + const projectA = "/tmp/project-a"; + const projectB = "/tmp/project-b"; + const idA1 = "ws-a1"; + const idA2 = "ws-a2"; + const idA3 = "ws-a3"; + const idB1 = "ws-b1"; + const idB2 = "ws-b2"; + + let workspaceService: WorkspaceService; + let configState: ProjectsConfig; + let historyService: HistoryService; + let cleanupHistory: () => Promise; + + const findEntry = (id: string) => { + for (const [projectPath, project] of configState.projects) { + const entry = project.workspaces.find((w) => w.id === id); + if (entry) return { projectPath, entry }; + } + return undefined; + }; + + /** Pinned ids across all projects in effective order (pinnedAt asc), as the flat sidebar sorts them. */ + const globalPinnedOrder = () => + [...configState.projects.values()] + .flatMap((project) => project.workspaces) + .filter((w) => w.id && w.pinnedAt && !w.parentWorkspaceId && !w.archivedAt) + .sort((a, b) => Date.parse(a.pinnedAt ?? "") - Date.parse(b.pinnedAt ?? "")) + .map((w) => w.id); + + beforeEach(async () => { + // Interleaved global pin order: a1, b1, a2, b2. + configState = { + projects: new Map([ + [ + projectA, + { + workspaces: [ + { path: `${projectA}/${idA1}`, id: idA1, pinnedAt: "2026-01-01T00:00:00.000Z" }, + { path: `${projectA}/${idA2}`, id: idA2, pinnedAt: "2026-01-01T00:00:20.000Z" }, + { path: `${projectA}/${idA3}`, id: idA3 }, + ], + }, + ], + [ + projectB, + { + workspaces: [ + { path: `${projectB}/${idB1}`, id: idB1, pinnedAt: "2026-01-01T00:00:10.000Z" }, + { path: `${projectB}/${idB2}`, id: idB2, pinnedAt: "2026-01-01T00:00:30.000Z" }, + ], + }, + ], + ]), + }; + + ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); + + const mockConfig: Partial = { + srcDir: "/tmp/src", + getSessionDir: mock(() => "/tmp/test/sessions"), + findWorkspace: mock((id: string) => { + const found = findEntry(id); + if (!found) return null; + return { + projectPath: found.projectPath, + workspacePath: found.entry.path, + parentWorkspaceId: found.entry.parentWorkspaceId, + }; + }), + editConfig: mock((fn: (config: ProjectsConfig) => ProjectsConfig) => { + configState = fn(configState); + return Promise.resolve(); + }), + getAllWorkspaceMetadata: mock(() => Promise.resolve([])), + loadConfigOrDefault: mock(() => configState), + }; + + workspaceService = createWorkspaceServiceForTest({ + config: mockConfig, + historyService, + }); + }); + + afterEach(async () => { + await cleanupHistory(); + }); + + test("persists a flat-mode reorder spanning project buckets", async () => { + const maxBefore = Math.max( + ...[idA1, idA2, idB1, idB2].map((id) => Date.parse(findEntry(id)?.entry.pinnedAt ?? "")) + ); + + // Drag b1 above a1 in the unified pinned block. + const result = await workspaceService.reorderPinned([idB1, idA1, idA2, idB2]); + expect(result.success).toBe(true); + expect(globalPinnedOrder()).toEqual([idB1, idA1, idA2, idB2]); + + // The timestamp pool is re-dealt, not inflated. + const maxAfter = Math.max( + ...[idA1, idA2, idB1, idB2].map((id) => Date.parse(findEntry(id)?.entry.pinnedAt ?? "")) + ); + expect(maxAfter).toBe(maxBefore); + }); + + test("setPinned appends after the global pinned max, not just its own bucket's", async () => { + // Give the other bucket the newest pin so a bucket-local max would sort the + // new pin above it in the flat sidebar's unified block. + const future = new Date(Date.now() + 60_000).toISOString(); + findEntry(idB2)!.entry.pinnedAt = future; + + expect((await workspaceService.setPinned(idA3, true)).success).toBe(true); + expect(globalPinnedOrder().at(-1)).toBe(idA3); + }); + + test("partial cross-bucket reorder keeps omitted pins in their global slots", async () => { + // The grouped multi-project section sends only its own pinned ids, which + // can live in different project buckets. Swapping b1 and a2 must not + // displace the ordinary pins a1 and b2 in the flat global order. + const a1Before = findEntry(idA1)?.entry.pinnedAt; + const b2Before = findEntry(idB2)?.entry.pinnedAt; + + const result = await workspaceService.reorderPinned([idA2, idB1]); + expect(result.success).toBe(true); + expect(globalPinnedOrder()).toEqual([idA1, idA2, idB1, idB2]); + // The untouched slots keep their exact timestamps. + expect(findEntry(idA1)?.entry.pinnedAt).toBe(a1Before); + expect(findEntry(idB2)?.entry.pinnedAt).toBe(b2Before); + }); + + test("grouped-mode reorder of one bucket leaves other buckets' timestamps untouched", async () => { + const b1Before = findEntry(idB1)?.entry.pinnedAt; + const b2Before = findEntry(idB2)?.entry.pinnedAt; + + const result = await workspaceService.reorderPinned([idA2, idA1]); + expect(result.success).toBe(true); + + // Project A flipped within its own timestamp pool. + const a1 = Date.parse(findEntry(idA1)?.entry.pinnedAt ?? ""); + const a2 = Date.parse(findEntry(idA2)?.entry.pinnedAt ?? ""); + expect(a2).toBeLessThan(a1); + // Project B was not referenced, so its entries are byte-identical. + expect(findEntry(idB1)?.entry.pinnedAt).toBe(b1Before); + expect(findEntry(idB2)?.entry.pinnedAt).toBe(b2Before); + }); +}); + describe("WorkspaceService archive lifecycle hooks", () => { const workspaceId = "ws-archive"; const projectPath = "/tmp/project"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 6393454098e..b31e037254d 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -14,6 +14,7 @@ import { isWorkspaceArchived } from "@/common/utils/archive"; import { comparePinnedOrder, isWorkspacePinned, + appendPinnedTimestamp, reassignPinnedTimestamps, } from "@/common/utils/pin"; import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; @@ -8062,6 +8063,7 @@ export class WorkspaceService extends EventEmitter { const { projectPath, workspacePath } = workspace; let updated = false; + const healedIds: string[] = []; let validationError: string | undefined; await this.config.editConfig((config) => { const projectConfig = config.projects.get(projectPath); @@ -8093,18 +8095,28 @@ export class WorkspaceService extends EventEmitter { if (workspaceEntry.pinnedAt) { return config; } - // Server-generated monotonic timestamp: strictly greater than every existing - // pin in the project so rapid pins always append deterministically, even if - // the wall clock is skewed or several pins land within the same millisecond. - let pinnedAtMs = Date.now(); - for (const entry of projectConfig.workspaces) { - if (!entry.pinnedAt) continue; - const existingMs = new Date(entry.pinnedAt).getTime(); - if (Number.isFinite(existingMs) && existingMs >= pinnedAtMs) { - pinnedAtMs = existingMs + 1; + // Server-generated global monotonic timestamp, plus write-path + // healing when corrupted state saturates the sane key range; see + // appendPinnedTimestamp. + const pinnedEntries = Array.from(config.projects.values()).flatMap((project) => + project.workspaces + .filter((entry) => entry.pinnedAt) + .map((entry) => ({ id: entry.id, pinnedAt: entry.pinnedAt })) + ); + const { changed, pinnedAt } = appendPinnedTimestamp(pinnedEntries); + if (changed.size > 0) { + for (const project of config.projects.values()) { + for (const entry of project.workspaces) { + if (!entry.id) continue; + const healedPinnedAt = changed.get(entry.id); + if (healedPinnedAt !== undefined) { + entry.pinnedAt = healedPinnedAt; + healedIds.push(entry.id); + } + } } } - workspaceEntry.pinnedAt = new Date(pinnedAtMs).toISOString(); + workspaceEntry.pinnedAt = pinnedAt; updated = true; } else if (workspaceEntry.pinnedAt) { delete workspaceEntry.pinnedAt; @@ -8118,6 +8130,9 @@ export class WorkspaceService extends EventEmitter { return Err(validationError); } + if (healedIds.length > 0) { + await this.emitCurrentWorkspaceMetadataBatch(healedIds); + } if (updated) { await this.emitCurrentWorkspaceMetadata(workspaceId); } @@ -8129,11 +8144,18 @@ export class WorkspaceService extends EventEmitter { } /** - * Reorder the pinned block of one project bucket. `workspaceIds` is the full - * desired pinned order for that bucket as the client sees it. Defensive - * contract: unknown/unpinned ids are dropped, currently-pinned ids omitted - * from the input keep their relative order and are appended, so concurrent - * pin/unpin from other clients is absorbed instead of erroring. + * Reorder a pinned block. `workspaceIds` is the full desired pinned order + * for that block as the client sees it: one project bucket in grouped mode, + * or the unified cross-project block in flat sidebar mode. The reorder + * scope is the union of config buckets referenced by the input ids, so a + * grouped drag never disturbs other buckets while a flat drag re-deals the + * whole unified block. Defensive contract: unknown/unpinned ids are + * dropped, and partial inputs (e.g. the grouped multi-project section sends + * only its own pins, which can span project buckets) permute the requested + * ids among the slots they already occupy while every omitted pin keeps its + * current position, so a section drag never shifts unrelated chats in the + * flat global order and concurrent pin/unpin from other clients is absorbed + * instead of erroring. * * Persistence model: pinnedAt is an ordering key, so reordering re-deals the * existing pool of pinnedAt timestamps onto the new order (see @@ -8142,30 +8164,34 @@ export class WorkspaceService extends EventEmitter { */ async reorderPinned(workspaceIds: string[]): Promise> { try { - // Derive the config bucket from the first resolvable id so clients never - // need internal bucket keys (e.g. the multi-project bucket). Nothing - // resolvable means the client acted on stale state: a benign no-op. - // Const (not narrowed let) so the editConfig closure sees type string. - const projectPath = workspaceIds - .map((id) => this.config.findWorkspace(id)?.projectPath) - .find((path) => path !== undefined); - if (projectPath === undefined) { + // Resolve buckets from the ids so clients never need internal bucket + // keys (e.g. the multi-project bucket). Nothing resolvable means the + // client acted on stale state: a benign no-op. + const projectPaths = new Set(); + for (const id of workspaceIds) { + const path = this.config.findWorkspace(id)?.projectPath; + if (path !== undefined) { + projectPaths.add(path); + } + } + if (projectPaths.size === 0) { return Ok(undefined); } const changedIds: string[] = []; await this.config.editConfig((config) => { - const projectConfig = config.projects.get(projectPath); - if (!projectConfig) { - return config; - } + const bucketConfigs = [...projectPaths] + .map((path) => config.projects.get(path)) + .filter((bucket) => bucket !== undefined); - // Current pinned roots of the bucket, in effective pin order. + // Current pinned roots across the referenced buckets, in effective pin order. const pinnedEntries: Array<{ id: string; pinnedAt: string }> = []; - for (const entry of projectConfig.workspaces) { - if (!entry.id || !entry.pinnedAt) continue; - if (!isWorkspacePinned(entry)) continue; - pinnedEntries.push({ id: entry.id, pinnedAt: entry.pinnedAt }); + for (const bucket of bucketConfigs) { + for (const entry of bucket.workspaces) { + if (!entry.id || !entry.pinnedAt) continue; + if (!isWorkspacePinned(entry)) continue; + pinnedEntries.push({ id: entry.id, pinnedAt: entry.pinnedAt }); + } } if (pinnedEntries.length < 2) { return config; @@ -8175,21 +8201,22 @@ export class WorkspaceService extends EventEmitter { const currentSet = new Set(currentOrder); // Desired order: dedupe the input, keep only currently-pinned ids, - // then append omitted pins in their current relative order. + // then substitute the requested ids into the slots they currently + // occupy so omitted pins never move. const seen = new Set(); - const desiredOrder: string[] = []; + const requestedIds: string[] = []; for (const id of workspaceIds) { if (seen.has(id)) continue; seen.add(id); if (currentSet.has(id)) { - desiredOrder.push(id); - } - } - for (const id of currentOrder) { - if (!seen.has(id)) { - desiredOrder.push(id); + requestedIds.push(id); } } + const requestedSet = new Set(requestedIds); + let nextRequestedIndex = 0; + const desiredOrder = currentOrder.map((id) => + requestedSet.has(id) ? requestedIds[nextRequestedIndex++] : id + ); if (desiredOrder.every((id, index) => id === currentOrder[index])) { return config; } @@ -8198,12 +8225,14 @@ export class WorkspaceService extends EventEmitter { pinnedEntries.map((entry) => [entry.id, entry.pinnedAt]) ); const changes = reassignPinnedTimestamps(desiredOrder, currentPinnedAtById); - for (const entry of projectConfig.workspaces) { - if (!entry.id) continue; - const nextPinnedAt = changes.get(entry.id); - if (nextPinnedAt !== undefined) { - entry.pinnedAt = nextPinnedAt; - changedIds.push(entry.id); + for (const bucket of bucketConfigs) { + for (const entry of bucket.workspaces) { + if (!entry.id) continue; + const nextPinnedAt = changes.get(entry.id); + if (nextPinnedAt !== undefined) { + entry.pinnedAt = nextPinnedAt; + changedIds.push(entry.id); + } } } return config;