From 0f4d0c4b72e6af7288adbed688756285e304d00c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:37:06 +0000 Subject: [PATCH 01/17] feat(sidebar): add flat chat list mode --- src/browser/App.tsx | 4 +- .../AgentListItem/AgentListItem.tsx | 14 + .../ProjectSidebar/ProjectSidebar.stories.tsx | 54 ++++ .../ProjectSidebar/ProjectSidebar.test.tsx | 62 +++- .../ProjectSidebar/ProjectSidebar.tsx | 222 +++++++++++++- .../Settings/Sections/GeneralSection.test.tsx | 14 +- .../Settings/Sections/GeneralSection.tsx | 283 ++++++++++-------- src/browser/utils/commandIds.ts | 1 + src/browser/utils/commands/sources.test.ts | 33 +- src/browser/utils/commands/sources.ts | 11 + src/browser/utils/ui/pinnedReorder.test.ts | 20 ++ src/browser/utils/ui/pinnedReorder.ts | 16 +- .../utils/ui/workspaceFiltering.test.ts | 55 ++++ src/browser/utils/ui/workspaceFiltering.ts | 87 ++++-- src/common/constants/storage.ts | 6 + 15 files changed, 720 insertions(+), 162 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index f599e83d551..a624b6f8cbf 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,8 @@ function AppInner() { meta, direction, sortedWorkspacesByProject, - userProjects + userProjects, + readPersistedState(SIDEBAR_FLAT_MODE_KEY, false) ); if (order) void reorderPinnedWorkspaces(order); }, diff --git a/src/browser/components/AgentListItem/AgentListItem.tsx b/src/browser/components/AgentListItem/AgentListItem.tsx index 3b3cea3fb29..11f6b2244df 100644 --- a/src/browser/components/AgentListItem/AgentListItem.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.tsx @@ -105,6 +105,7 @@ interface AgentListItemBaseProps { isSelected: boolean; depth?: number; sectionId?: string; + projectBadge?: { name: string; color: string }; } /** Props for regular (persisted) workspace items */ @@ -1319,6 +1320,19 @@ function RegularAgentListItemInner(props: AgentListItemProps) { > {suppressGroupMemberTitle ? memberOnlyLabel : workspaceTitle} + {props.projectBadge && ( + + {props.projectBadge.name} + + )} {groupLabel && !suppressGroupMemberTitle && ( ( + { + updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true); + 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 }); + }} + /> + ), +}; // 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..2b19e3a1179 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; + projectBadge?: { name: string; color: string }; delegatedActivity?: { activeCount: number; queuedCount: number }; hiddenSubAgentsSummary?: WorkspaceSubAgentsSummary; getWorkflowRunName?: (runId: string) => string | undefined; @@ -369,6 +374,7 @@ function installProjectSidebarTestDoubles() { )} > {displayTitle} + {props.projectBadge ? {props.projectBadge.name} : null} {hasCompletedChildren && props.onToggleCompletedChildren ? ( + {isExpanded && ( + <> + {renderFlatWorkspaceRows(bucket)} + {nextTier !== -1 && renderFlatAgeTier(nextTier)} + + )} + + ); + }; + + const flatSidebarContent = ( +
+ + {flatDrafts.map(({ projectPath, draft }, index) => { + const isSelected = + pendingNewWorkspaceProject === projectPath && + pendingNewWorkspaceDraftId === draft.draftId; + return ( + { + handleDraftVisibilityChange(projectPath, draft.draftId, isVisible); + }} + onOpen={() => handleOpenWorkspaceDraft(projectPath, draft.draftId)} + onDelete={() => { + if (isSelected) navigateToProject(projectPath); + deleteWorkspaceDraft(projectPath, draft.draftId); + }} + /> + ); + })} + {flatAgePartition ? ( + <> + {renderFlatWorkspaceRows(flatAgePartition.recent)} + {firstFlatAgeTier !== -1 && renderFlatAgeTier(firstFlatAgeTier)} + + ) : ( + renderFlatWorkspaceRows(visibleFlatWorkspaces) + )} +
+ ); + return ( = ({ onViewportScroll={handleProjectListScroll} viewportClassName="overflow-x-hidden" > -
+ {flatSidebarEnabled && flatSidebarContent} +
)} -
+ - {multiProjectWorkspaces.length > 0 && ( + {!flatSidebarEnabled && multiProjectWorkspaces.length > 0 && (
)} - {sortedProjectPaths.length === 0 && multiProjectWorkspaces.length === 0 ? ( + {!flatSidebarEnabled && + groupedProjectPaths.length === 0 && + multiProjectWorkspaces.length === 0 ? (

No projects

@@ -2232,7 +2436,7 @@ const ProjectSidebarInner: React.FC = ({
) : ( - sortedProjectPaths.map((projectPath) => { + groupedProjectPaths.map((projectPath) => { const config = userProjects.get(projectPath); if (!config) return null; const projectFolderColor = config.color 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..831ce29362c 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/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..ffe0a1fd40b 100644 --- a/src/browser/utils/ui/pinnedReorder.test.ts +++ b/src/browser/utils/ui/pinnedReorder.test.ts @@ -139,6 +139,26 @@ 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(), true)).toEqual({ + fullOrder: ["b", "a"], + blockIds: ["b", "a"], + }); + }); + 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..cd5bf86c19e 100644 --- a/src/browser/utils/ui/pinnedReorder.ts +++ b/src/browser/utils/ui/pinnedReorder.ts @@ -56,10 +56,19 @@ function collectFlatSectionRows( export function locatePinnedBlock( meta: FrontendWorkspaceMetadata, sortedWorkspacesByProject: Map, - userProjects: Map + userProjects: Map, + flatMode = false ): PinnedBlock | null { if (!isWorkspacePinned(meta)) return null; + if (flatMode) { + const pinnedIds = collectFlatSectionRows(sortedWorkspacesByProject, () => true) + .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 +139,10 @@ export function computePinnedMoveOrderForWorkspace( meta: FrontendWorkspaceMetadata, direction: PinnedMoveDirection, sortedWorkspacesByProject: Map, - userProjects: Map + userProjects: Map, + flatMode = 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..70054ab166c 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,60 @@ describe("buildSortedWorkspacesByProject", () => { }); }); +describe("buildSortedWorkspacesFlat", () => { + it("sorts all workspace kinds globally and keeps children under their parent", () => { + const projects = new Map([ + ["/project/a", { workspaces: [{ path: "/a/pinned-late", id: "pinned-late" }] }], + ["/project/b", { workspaces: [{ path: "/b/pinned-early", id: "pinned-early" }] }], + ]); + 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(projects, metadata, { + 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..101b2dfa594 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,36 @@ export function buildSortedWorkspacesByProject( return result; } +/** Build one globally sorted workspace tree for the optional flat sidebar. */ +export function buildSortedWorkspacesFlat( + projects: Map, + workspaceMetadata: Map, + workspaceRecency: Record +): FrontendWorkspaceMetadata[] { + const workspaces: FrontendWorkspaceMetadata[] = []; + const includedIds = new Set(); + + for (const config of projects.values()) { + for (const workspace of config.workspaces) { + if (!workspace.id || includedIds.has(workspace.id)) continue; + const metadata = workspaceMetadata.get(workspace.id); + if (metadata) { + workspaces.push(metadata); + includedIds.add(workspace.id); + } + } + } + + for (const [id, metadata] of workspaceMetadata) { + if (!includedIds.has(id)) { + workspaces.push(metadata); + } + } + + sortWorkspaceRows(workspaces, workspaceRecency); + return flattenWorkspaceTree(workspaces); +} + /** * 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. From e221c92ec99b75492eaf6299461db7452378520c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:54:07 +0000 Subject: [PATCH 02/17] fix(sidebar): persist cross-project pinned reorders in flat mode reorderPinned scoped the re-deal to the first id's project bucket, so a flat-mode drag spanning projects hit the <2 pinned early-return (or only rewrote one bucket) and the optimistic client order reverted on reload. Scope the reorder to the union of buckets referenced by the input ids: grouped drags keep single-bucket behavior, flat drags re-deal the whole unified block's timestamp pool. --- src/node/services/workspaceService.test.ts | 120 +++++++++++++++++++++ src/node/services/workspaceService.ts | 66 +++++++----- 2 files changed, 158 insertions(+), 28 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index ec69f044d2d..e0512081861 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -13614,6 +13614,126 @@ 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 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" }, + ], + }, + ], + [ + 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("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..2790717ae0f 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -8129,11 +8129,15 @@ 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, 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. * * Persistence model: pinnedAt is an ordering key, so reordering re-deals the * existing pool of pinnedAt timestamps onto the new order (see @@ -8142,30 +8146,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; @@ -8198,12 +8206,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; From 6cee23bc30c5f65ab0ac366734de4348c8703fad Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:34:11 +0000 Subject: [PATCH 03/17] refactor(sidebar): cleanup-gate fixes from milestone audit - render the project badge on draft rows (prop was threaded but unused) - gate flat-list derivation behind the flag so grouped mode skips the global sort/flatten work - simplify buildSortedWorkspacesFlat to take rows directly (the two-pass config merge reproduced Array.from(map.values()) before a global sort) - dedupe project badge resolution; inline single-use GroupedSidebarSection - drop leftover divide-y padding on the API Debug Logs row - document flat mode in locatePinnedBlock's JSDoc --- .../AgentListItem/AgentListItem.tsx | 13 + .../ProjectSidebar/ProjectSidebar.tsx | 263 +++++++++--------- .../Settings/Sections/GeneralSection.tsx | 4 +- src/browser/utils/ui/pinnedReorder.ts | 5 +- .../utils/ui/workspaceFiltering.test.ts | 6 +- src/browser/utils/ui/workspaceFiltering.ts | 28 +- 6 files changed, 151 insertions(+), 168 deletions(-) diff --git a/src/browser/components/AgentListItem/AgentListItem.tsx b/src/browser/components/AgentListItem/AgentListItem.tsx index 11f6b2244df..5461d26f7e9 100644 --- a/src/browser/components/AgentListItem/AgentListItem.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.tsx @@ -491,6 +491,19 @@ function DraftAgentListItemInner(props: DraftAgentListItemProps) { > {draft.title} + {props.projectBadge && ( + + {props.projectBadge.name} + + )}
{hasPromptPreview && ( {props.children}
: null; -} - // Custom drag layer to show a semi-transparent preview and enforce grabbing cursor interface ProjectDragItem { type: "PROJECT"; @@ -1744,14 +1740,9 @@ const ProjectSidebarInner: React.FC = ({ const isWorkflowRunActive = (workspaceId: string, runId: string): boolean => getActiveWorkflowRunIds(workspaceId).includes(runId); const allSidebarWorkspaces = Array.from(sortedWorkspacesByProject.values()).flat(); - const flatWorkspaceMetadata = new Map( - allSidebarWorkspaces.map((workspace) => [workspace.id, workspace] as const) - ); - const flatWorkspaces = buildSortedWorkspacesFlat( - userProjects, - flatWorkspaceMetadata, - workspaceRecency - ); + const flatWorkspaces = flatSidebarEnabled + ? buildSortedWorkspacesFlat(allSidebarWorkspaces, workspaceRecency) + : []; const flatRowsForDisplay = hideSubAgentRows ? excludeSubAgentRows(flatWorkspaces) : flatWorkspaces; @@ -1886,6 +1877,13 @@ const ProjectSidebarInner: React.FC = ({ .flatMap(([projectPath, drafts]) => drafts.map((draft) => ({ projectPath, draft }))) .sort((a, b) => b.draft.createdAt - a.draft.createdAt); const groupedProjectPaths = flatSidebarEnabled ? [] : sortedProjectPaths; + const getProjectBadge = (projectPath: string): { name: string; color: string } => { + const config = userProjects.get(projectPath); + return { + name: config?.displayName ?? getProjectFallbackLabel(projectPath), + color: resolveSectionColor(config?.color), + }; + }; const getFlatProjectBadge = ( workspace: FrontendWorkspaceMetadata ): { name: string; color: string } | undefined => { @@ -1893,20 +1891,10 @@ const ProjectSidebarInner: React.FC = ({ if (isMultiProject(workspace)) { return { name: "Multi-project", color: resolveSectionColor(undefined) }; } - const config = userProjects.get(workspace.projectPath); - return { - name: config?.displayName ?? getProjectFallbackLabel(workspace.projectPath), - color: resolveSectionColor(config?.color), - }; - }; - const getFlatDraftBadge = (projectPath: string): { name: string; color: string } | undefined => { - if (projectPath === SCRATCH_PROJECT_CONFIG_KEY) return undefined; - const config = userProjects.get(projectPath); - return { - name: config?.displayName ?? getProjectFallbackLabel(projectPath), - color: resolveSectionColor(config?.color), - }; + return getProjectBadge(workspace.projectPath); }; + const getFlatDraftBadge = (projectPath: string): { name: string; color: string } | undefined => + projectPath === SCRATCH_PROJECT_CONFIG_KEY ? undefined : getProjectBadge(projectPath); const handleReorder = useCallback( (draggedPath: string, targetPath: string) => { @@ -2223,119 +2211,124 @@ const ProjectSidebarInner: React.FC = ({ viewportClassName="overflow-x-hidden" > {flatSidebarEnabled && flatSidebarContent} - -
- -
- 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 && ( + + )} +
+ )} +
+ )} {!flatSidebarEnabled && multiProjectWorkspaces.length > 0 && (
diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 831ce29362c..1bfb025c240 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -979,8 +979,8 @@ export function GeneralSection() {
)} -
-
+
+
API Debug Logs
Record the full input and output of every AI API call diff --git a/src/browser/utils/ui/pinnedReorder.ts b/src/browser/utils/ui/pinnedReorder.ts index cd5bf86c19e..45a548cb78a 100644 --- a/src/browser/utils/ui/pinnedReorder.ts +++ b/src/browser/utils/ui/pinnedReorder.ts @@ -50,8 +50,9 @@ 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, diff --git a/src/browser/utils/ui/workspaceFiltering.test.ts b/src/browser/utils/ui/workspaceFiltering.test.ts index 70054ab166c..5a0a91433f6 100644 --- a/src/browser/utils/ui/workspaceFiltering.test.ts +++ b/src/browser/utils/ui/workspaceFiltering.test.ts @@ -631,10 +631,6 @@ describe("buildSortedWorkspacesByProject", () => { describe("buildSortedWorkspacesFlat", () => { it("sorts all workspace kinds globally and keeps children under their parent", () => { - const projects = new Map([ - ["/project/a", { workspaces: [{ path: "/a/pinned-late", id: "pinned-late" }] }], - ["/project/b", { workspaces: [{ path: "/b/pinned-early", id: "pinned-early" }] }], - ]); const parent = { ...createWorkspace("parent", "/project/a"), projects: [ @@ -666,7 +662,7 @@ describe("buildSortedWorkspacesFlat", () => { ["recent", createWorkspace("recent", "/project/b")], ]); - const result = buildSortedWorkspacesFlat(projects, metadata, { + const result = buildSortedWorkspacesFlat([...metadata.values()], { parent: 300, child: 1, scratch: 200, diff --git a/src/browser/utils/ui/workspaceFiltering.ts b/src/browser/utils/ui/workspaceFiltering.ts index 101b2dfa594..29314bcbbe3 100644 --- a/src/browser/utils/ui/workspaceFiltering.ts +++ b/src/browser/utils/ui/workspaceFiltering.ts @@ -1020,32 +1020,12 @@ export function buildSortedWorkspacesByProject( /** Build one globally sorted workspace tree for the optional flat sidebar. */ export function buildSortedWorkspacesFlat( - projects: Map, - workspaceMetadata: Map, + workspaces: FrontendWorkspaceMetadata[], workspaceRecency: Record ): FrontendWorkspaceMetadata[] { - const workspaces: FrontendWorkspaceMetadata[] = []; - const includedIds = new Set(); - - for (const config of projects.values()) { - for (const workspace of config.workspaces) { - if (!workspace.id || includedIds.has(workspace.id)) continue; - const metadata = workspaceMetadata.get(workspace.id); - if (metadata) { - workspaces.push(metadata); - includedIds.add(workspace.id); - } - } - } - - for (const [id, metadata] of workspaceMetadata) { - if (!includedIds.has(id)) { - workspaces.push(metadata); - } - } - - sortWorkspaceRows(workspaces, workspaceRecency); - return flattenWorkspaceTree(workspaces); + const rows = [...workspaces]; + sortWorkspaceRows(rows, workspaceRecency); + return flattenWorkspaceTree(rows); } /** From 2866aec8b179dd29b940c440f6188c19df750d91 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:21:17 +0000 Subject: [PATCH 04/17] fix(sidebar): address Codex round-1 review findings - setPinned scans all project buckets for the global pinnedAt max so new pins append at the bottom of the flat unified pinned block - flat collection respects the multi-project experiment gate - project badge props are stable primitives; badge text uses text-secondary for contrast, keeping the project color as a tinted background/border - workspace and draft aria-labels include the project badge name - Storybook shared reset clears SIDEBAR_FLAT_MODE_KEY - flat draft deletion selects an adjacent draft; draft promotion renders the promoted workspace once in the draft's position - extracted one shared coalesced list pipeline so flat mode gets task-group coalescing (best-of + workflow runs) identical to grouped mode --- .../AgentListItem/AgentListItem.tsx | 68 +- .../ProjectSidebar/ProjectSidebar.test.tsx | 136 +- .../ProjectSidebar/ProjectSidebar.tsx | 1111 ++++++++--------- src/browser/stories/meta.tsx | 4 + src/node/services/workspaceService.test.ts | 12 + src/node/services/workspaceService.ts | 18 +- 6 files changed, 733 insertions(+), 616 deletions(-) diff --git a/src/browser/components/AgentListItem/AgentListItem.tsx b/src/browser/components/AgentListItem/AgentListItem.tsx index 5461d26f7e9..e0f85ed6cf5 100644 --- a/src/browser/components/AgentListItem/AgentListItem.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.tsx @@ -105,7 +105,9 @@ interface AgentListItemBaseProps { isSelected: boolean; depth?: number; sectionId?: string; - projectBadge?: { name: string; color: string }; + // Stable primitives (not an object) so React Compiler can skip unchanged rows. + projectBadgeName?: string; + projectBadgeColor?: string; } /** Props for regular (persisted) workspace items */ @@ -472,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} > @@ -491,17 +497,20 @@ function DraftAgentListItemInner(props: DraftAgentListItemProps) { > {draft.title} - {props.projectBadge && ( + {props.projectBadgeName != null && ( - {props.projectBadge.name} + {props.projectBadgeName} )}
@@ -1055,15 +1064,21 @@ function RegularAgentListItemInner(props: AgentListItemProps) { aria-current={isSelected ? "true" : undefined} aria-expanded={canToggleCompletedChildren ? isCompletedChildrenExpanded : undefined} aria-keyshortcuts={canToggleCompletedChildren ? "ArrowRight ArrowLeft" : undefined} - aria-label={ - isRemoving - ? `Deleting workspace ${displayTitle}` + aria-label={(() => { + // 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} @@ -1333,17 +1348,20 @@ function RegularAgentListItemInner(props: AgentListItemProps) { > {suppressGroupMemberTitle ? memberOnlyLabel : workspaceTitle} - {props.projectBadge && ( + {props.projectBadgeName != null && ( - {props.projectBadge.name} + {props.projectBadgeName} )} {groupLabel && !suppressGroupMemberTitle && ( diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 2b19e3a1179..98221c3f453 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -127,7 +127,7 @@ interface MockAgentListItemProps { }; depth?: number; rowRenderMeta?: AgentRowRenderMeta; - projectBadge?: { name: string; color: string }; + projectBadgeName?: string; delegatedActivity?: { activeCount: number; queuedCount: number }; hiddenSubAgentsSummary?: WorkspaceSubAgentsSummary; getWorkflowRunName?: (runId: string) => string | undefined; @@ -374,7 +374,7 @@ function installProjectSidebarTestDoubles() { )} > {displayTitle} - {props.projectBadge ? {props.projectBadge.name} : null} + {props.projectBadgeName != null ? {props.projectBadgeName} : null} {hasCompletedChildren && props.onToggleCompletedChildren ? ( + {isTierExpanded && ( + <> + {renderWorkspaceRows(bucket)} + {(() => { + const nextTier = findNextNonEmptyTier(buckets, tierIndex + 1); + return nextTier !== -1 ? renderTier(nextTier) : null; + })()} + + )} + + ); + }; - const flatAgePartition = ageGroupingEnabled - ? partitionWorkspacesByAge(visibleFlatWorkspaces, workspaceRecency) - : null; - const firstFlatAgeTier = flatAgePartition - ? findNextNonEmptyTier(flatAgePartition.buckets, 0) - : -1; - const renderFlatAgeTier = (tierIndex: number): React.ReactNode => { - if (!flatAgePartition) return null; - const bucket = flatAgePartition.buckets[tierIndex]; - const remainingCount = flatAgePartition.buckets - .slice(tierIndex) - .reduce((sum, rows) => sum + rows.length, 0); - if (remainingCount === 0) return null; - - const tierKey = `flat:${tierIndex}`; - const isExpanded = expandedOldWorkspaces[tierKey] ?? false; - const thresholdLabel = formatDaysThreshold(AGE_THRESHOLDS_DAYS[tierIndex]); - const nextTier = findNextNonEmptyTier(flatAgePartition.buckets, tierIndex + 1); return ( - - - {isExpanded && ( - <> - {renderFlatWorkspaceRows(bucket)} - {nextTier !== -1 && renderFlatAgeTier(nextTier)} - - )} - + <> + {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 ( + ); }; @@ -2129,9 +2533,19 @@ const ProjectSidebarInner: React.FC = ({ New chat {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); return ( = ({ draftId={draft.draftId} draftNumber={index + 1} isSelected={isSelected} - projectBadge={getFlatDraftBadge(projectPath)} + projectBadgeName={draftBadge?.name} + projectBadgeColor={draftBadge?.color} onVisibilityChange={(isVisible) => { handleDraftVisibilityChange(projectPath, draft.draftId, isVisible); }} onOpen={() => handleOpenWorkspaceDraft(projectPath, draft.draftId)} onDelete={() => { - if (isSelected) navigateToProject(projectPath); + 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); }} /> ); })} - {flatAgePartition ? ( - <> - {renderFlatWorkspaceRows(flatAgePartition.recent)} - {firstFlatAgeTier !== -1 && renderFlatAgeTier(firstFlatAgeTier)} - - ) : ( - renderFlatWorkspaceRows(visibleFlatWorkspaces) - )} + {renderCoalescedWorkspaceList(visibleFlatWorkspaces, flatRowsForDisplay, { + tierKeyPrefix: "flat", + depthByWorkspaceId: flatDepthByWorkspaceId, + baseRowMetaByWorkspaceId: flatRowMetaByWorkspaceId, + renderRow: renderFlatRow, + })}
); @@ -2801,186 +3224,6 @@ const ProjectSidebarInner: React.FC = ({ ); }; - const renderTaskGroupRows = (params: { - group: SidebarTaskGroupModel; - sectionId?: string; - rowMetaByWorkspaceId: ReadonlyMap; - memberMetaByWorkspaceId: ReadonlyMap; - }): React.ReactNode[] => { - const headerMeta = params.rowMetaByWorkspaceId.get( - params.group.storageKey - ); - 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 headerRow = ( - { - toggleTaskGroupExpansion(params.group.storageKey, isExpanded); - }} - /> - ); - 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 - ) - ); - } - } - 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, - }) - ); - } - 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; - } - - renderedRows.push( - ...renderTaskGroupRows({ - group, - sectionId, - rowMetaByWorkspaceId, - memberMetaByWorkspaceId, - }) - ); - } - - return renderedRows; - }; - const renderDraft = ( draft: (typeof sortedDrafts)[number] ): React.ReactNode => { @@ -3048,325 +3291,29 @@ const ProjectSidebarInner: React.FC = ({ 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[] => [] + ): 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 ), - }; - - 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); } - }; - - 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, - }); - - for (const group of taskGroups.groupsByStorageKey.values()) { - if ( - group.kind === "workflow" && - (group.hasActiveMember || - sessionActiveTaskGroupKeysRef.current.has(group.storageKey)) - ) { - retainedWorkflowTaskGroupsRef.current.set( - group.storageKey, - group - ); - } - } - - 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 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 - ); - } - - 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; - } - 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); - - if (remainingCount === 0) return null; - - 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; - - 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, diff --git a/src/browser/stories/meta.tsx b/src/browser/stories/meta.tsx index 7fd38cd71c9..d9724cf13a4 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,9 @@ 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 so later + // stories keep their project folders. + localStorage.removeItem(SIDEBAR_FLAT_MODE_KEY); // 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/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index e0512081861..1c63e9f5fb2 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -13619,6 +13619,7 @@ describe("WorkspaceService reorderPinned across projects", () => { 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"; @@ -13653,6 +13654,7 @@ describe("WorkspaceService reorderPinned across projects", () => { 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 }, ], }, ], @@ -13717,6 +13719,16 @@ describe("WorkspaceService reorderPinned across projects", () => { 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("grouped-mode reorder of one bucket leaves other buckets' timestamps untouched", async () => { const b1Before = findEntry(idB1)?.entry.pinnedAt; const b2Before = findEntry(idB2)?.entry.pinnedAt; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 2790717ae0f..dcb14826ce9 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -8094,14 +8094,18 @@ export class WorkspaceService extends EventEmitter { 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. + // pin across all projects so rapid pins always append deterministically, even + // if the wall clock is skewed or several pins land within the same millisecond. + // The global scan (not just this bucket) keeps the flat sidebar's unified + // pinned block appending at the bottom too. 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; + for (const project of config.projects.values()) { + for (const entry of project.workspaces) { + if (!entry.pinnedAt) continue; + const existingMs = new Date(entry.pinnedAt).getTime(); + if (Number.isFinite(existingMs) && existingMs >= pinnedAtMs) { + pinnedAtMs = existingMs + 1; + } } } workspaceEntry.pinnedAt = new Date(pinnedAtMs).toISOString(); From ccb84d83690f2c9ce4686b546e9c334306f40aea Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:45:49 +0000 Subject: [PATCH 05/17] fix(sidebar): address Codex round-2 review findings - reorderPinned substitutes requested ids into their existing slots so a partial input (grouped multi-project section spanning buckets) never displaces omitted pins in the flat global order (red-green tested) - flat mode renders compact project management headers below the chat list: per-project new chat, options menu, rename, and color stay reachable via mouse/touch without leaving flat mode - FlatChatList story pins a phone viewport variant plus a play contract that the flat list and headers are actually on screen --- .../ProjectSidebar/ProjectSidebar.stories.tsx | 37 +- .../ProjectSidebar/ProjectSidebar.test.tsx | 38 +- .../ProjectSidebar/ProjectSidebar.tsx | 1267 +++++++++-------- src/node/services/workspaceService.test.ts | 24 +- src/node/services/workspaceService.ts | 26 +- 5 files changed, 764 insertions(+), 628 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.stories.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.stories.tsx index a911cf4b995..00135b43495 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.stories.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.stories.tsx @@ -1,11 +1,15 @@ import { fireEvent, userEvent, waitFor } from "@storybook/test"; import type { AppStory } from "@/browser/stories/meta.js"; import { PIXEL_DUAL_THEME, appMeta, AppWithMocks } from "@/browser/stories/meta.js"; -import { expandProjects } from "@/browser/stories/helpers/uiState"; +import { + clearWorkspaceSelection, + collapseRightSidebar, + expandProjects, +} from "@/browser/stories/helpers/uiState"; import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; import { createWorkspace, groupWorkspacesByProject } from "@/browser/stories/mocks/workspaces"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { SIDEBAR_FLAT_MODE_KEY } from "@/common/constants/storage"; +import { LEFT_SIDEBAR_COLLAPSED_KEY, SIDEBAR_FLAT_MODE_KEY } from "@/common/constants/storage"; const PROJECT_PATH = "/home/user/projects/my-app"; @@ -302,13 +306,23 @@ export const WorkflowRunGroups: AppStory = { }; export const FlatChatList: AppStory = { + // The flat list replaces the whole sidebar layout, so validate the compact + // badge/truncation behavior at the phone width alongside the laptop capture. + globals: { + viewport: { value: "mobile2", isRotated: false }, + }, parameters: { - pixel: { matrix: PIXEL_DUAL_THEME }, + pixel: { matrix: { themes: ["dark", "light"], viewports: ["phone", "laptop"] } }, }, render: () => ( { 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", @@ -352,6 +366,23 @@ export const FlatChatList: AppStory = { }} /> ), + // 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 diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 98221c3f453..493cc339cf6 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -812,8 +812,14 @@ describe("ProjectSidebar scratch chats", () => { ); expect(view.queryByLabelText("Expand project alpha")).toBeNull(); - expect(view.getByText("Alpha Project")).toBeTruthy(); - expect(view.getByText("Beta Project")).toBeTruthy(); + // Badge names are asserted inside their rows: the project management + // headers below the flat list repeat the display names. + expect( + within(view.getByTestId(agentItemTestId("alpha"))).getByText("Alpha Project") + ).toBeTruthy(); + expect( + within(view.getByTestId(agentItemTestId("beta"))).getByText("Beta Project") + ).toBeTruthy(); const workspaceRows = Array.from( view.container.querySelectorAll('[data-testid^="agent-item-"]') ); @@ -937,6 +943,34 @@ describe("ProjectSidebar flat chat list", () => { expect(view.queryByTestId(agentItemTestId("multi"))).toBeNull(); }); + test("keeps project management headers reachable in flat mode without nesting chats", () => { + const workspace = { + ...createWorkspace("solo", { title: "Solo chat" }), + projects: singleProjectRefs, + }; + updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true); + // Grouped-mode expansion state must not nest chats under flat headers. + updatePersistedState(EXPANDED_PROJECTS_KEY, ["/projects/demo-project"]); + + const view = render( + undefined} + sortedWorkspacesByProject={new Map([["/projects/demo-project", [workspace]]])} + workspaceRecency={{ solo: Date.now() }} + /> + ); + + // Per-project creation and the options menu stay reachable via the + // compact header row; the expansion chevron is grouped-mode only. + expect(view.getByLabelText("Create workspace in demo-project")).toBeTruthy(); + expect(view.getByLabelText("Project options for demo-project")).toBeTruthy(); + expect(view.queryByLabelText("Expand project demo-project")).toBeNull(); + expect(view.queryByLabelText("Collapse project demo-project")).toBeNull(); + // The chat renders once in the flat list, never nested under the header. + expect(view.getAllByTestId(agentItemTestId("solo"))).toHaveLength(1); + }); + test("coalesces best-of children into a task group in the flat list", () => { const parentWorkspace = { ...createWorkspace("parent", { title: "Parent workspace" }), diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 84f94743e6e..a0386ebc71a 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -1942,7 +1942,12 @@ const ProjectSidebarInner: React.FC = ({ const flatDrafts = Object.entries(workspaceDraftsByProject) .flatMap(([projectPath, drafts]) => drafts.map((draft) => ({ projectPath, draft }))) .sort((a, b) => b.draft.createdAt - a.draft.createdAt); - const groupedProjectPaths = flatSidebarEnabled ? [] : sortedProjectPaths; + // Project headers render in both modes: grouped mode nests each project's + // chats under its header, while flat mode appends the headers below the + // flat chat list as a compact management section (per-project new chat, + // rename, color, delete) so core project operations stay reachable via + // mouse/touch without leaving flat mode. + const projectHeaderPaths = sortedProjectPaths; const getProjectBadge = (projectPath: string): { name: string; color: string } => { const config = userProjects.get(projectPath); return { @@ -2832,7 +2837,7 @@ const ProjectSidebarInner: React.FC = ({ )} {!flatSidebarEnabled && - groupedProjectPaths.length === 0 && + projectHeaderPaths.length === 0 && multiProjectWorkspaces.length === 0 ? (

No projects

@@ -2852,655 +2857,701 @@ const ProjectSidebarInner: React.FC = ({
) : ( - groupedProjectPaths.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 ( -
- { - if (projectContextMenu.suppressClickIfLongPress()) { - return; - } - if (isEditingProjectDisplayName) { - return; - } - handleAddWorkspace(projectPath); - }} - onContextMenu={(event) => handleOpenProjectMenu(event, projectPath)} - onTouchStart={(event) => - handleProjectContextMenuTouchStart(event, projectPath) - } - onTouchEnd={projectContextMenu.touchHandlers.onTouchEnd} - onTouchMove={projectContextMenu.touchHandlers.onTouchMove} - onKeyDown={(e: React.KeyboardEvent) => { - // Ignore key events from child buttons - if (e.target instanceof HTMLElement && e.target !== e.currentTarget) { - return; - } - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); + <> + {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 + ); + + return ( +
+ { + if (projectContextMenu.suppressClickIfLongPress()) { + return; + } + if (isEditingProjectDisplayName) { + return; + } handleAddWorkspace(projectPath); + }} + onContextMenu={(event) => handleOpenProjectMenu(event, projectPath)} + onTouchStart={(event) => + handleProjectContextMenuTouchStart(event, projectPath) } - }} - role="button" - tabIndex={0} - aria-expanded={isExpanded} - aria-controls={workspaceListId} - aria-label={`Create workspace in ${projectName}`} - data-project-path={projectPath} - > - -
handleOpenProjectMenu(event, projectPath)} - > - - - {isEditingProjectDisplayName ? ( - event.stopPropagation()} - onMouseDown={(event) => event.stopPropagation()} - onContextMenu={(event) => event.stopPropagation()} - onChange={(event) => { - setEditingProjectDisplayName(event.target.value); - }} - onKeyDown={(event) => { - stopKeyboardPropagation(event); - if (event.key === "Escape") { - event.preventDefault(); - skipNextProjectNameBlurCommitRef.current = true; - cancelProjectDisplayNameEditing(); - return; - } - - if (event.key === "Enter") { - event.preventDefault(); - event.currentTarget.blur(); - } - }} - onBlur={(event) => { - event.stopPropagation(); - if (skipNextProjectNameBlurCommitRef.current) { - skipNextProjectNameBlurCommitRef.current = false; - return; - } - void commitProjectDisplayNameEdit( - projectPath, - event.currentTarget.value - ); - }} - /> - ) : ( -
- - {displayProjectName} - - - ({projectAgentCount}) - -
- )} -
- {projectPath} -
-
- - - - - - New chat ({formatKeybind(KEYBINDS.NEW_WORKSPACE)}) - - - - + + ) : ( - - Project options - -
- - {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) + )} +
handleOpenProjectMenu(event, projectPath)} + > + + + {isEditingProjectDisplayName ? ( + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onContextMenu={(event) => event.stopPropagation()} + onChange={(event) => { + setEditingProjectDisplayName(event.target.value); + }} + onKeyDown={(event) => { + stopKeyboardPropagation(event); + if (event.key === "Escape") { + event.preventDefault(); + skipNextProjectNameBlurCommitRef.current = true; + cancelProjectDisplayNameEditing(); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + } + }} + onBlur={(event) => { + event.stopPropagation(); + if (skipNextProjectNameBlurCommitRef.current) { + skipNextProjectNameBlurCommitRef.current = false; + return; + } + void commitProjectDisplayNameEdit( + projectPath, + event.currentTarget.value + ); + }} + /> + ) : ( +
+ + {displayProjectName} + + + ({projectAgentCount}) + +
+ )} +
+ {projectPath} +
+
+ + + + + + New chat ({formatKeybind(KEYBINDS.NEW_WORKSPACE)}) + + + + + + + Project options + + + + {!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 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 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) + ); + }); + 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 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); + 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 ( + + ); + }; - return ( - - ); - }; - - 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 renderDraft = ( + draft: (typeof sortedDrafts)[number] + ): React.ReactNode => { + const sectionId = getDraftSectionId(draft); + const promotedMetadata = activeDraftPromotions[draft.draftId]; - const draftNumber = draftNumberById.get(draft.draftId) ?? 0; - const isSelected = - pendingNewWorkspaceProject === projectPath && - pendingNewWorkspaceDraftId === draft.draftId; + if (promotedMetadata) { + const liveMetadata = + projectWorkspaces.find( + (workspace) => workspace.id === promotedMetadata.id + ) ?? promotedMetadata; + return renderWorkspace(liveMetadata, sectionId ?? 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 draftNumber = draftNumberById.get(draft.draftId) ?? 0; + const isSelected = + pendingNewWorkspaceProject === projectPath && + pendingNewWorkspaceDraftId === draft.draftId; + + return ( + { + handleDraftVisibilityChange( + projectPath, + draft.draftId, + isVisible ); - const fallback = - currentIndex >= 0 - ? (sortedDrafts[currentIndex + 1] ?? - sortedDrafts[currentIndex - 1]) - : undefined; - - if (fallback) { - openWorkspaceDraft(projectPath, fallback.draftId); - } else { - navigateToProject(sectionId ?? projectPath); - } + }} + 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); - }} - /> + deleteWorkspaceDraft(projectPath, draft.draftId); + }} + /> + ); + }; + + // 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 + ), + } + ); + + // 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 ); - }; - - // 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 - ), - } + const { unsectioned, bySectionId } = partitionWorkspacesBySection( + visibleWorkspacesForNormalRendering, + sections ); - // 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 + // 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; + } ); - 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 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; + 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); + return ( + + { - void updateProjectColor(section.id, color); - }} - autoStartEditing={shouldAutoEditSection} - onAutoCreateAbandon={ - shouldAutoEditSection - ? () => { - void (async () => { + hasAttention={sectionHasAttention} + onToggleExpand={() => + 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); - 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 ? ( - renderAgeTiers( - sectionWorkspaces, - getSectionTierKey(projectPath, section.id, 0).replace( - ":tier:0", - ":tier" - ), + } + : undefined + } + onDelete={(anchorEl) => { + void handleRemoveSection( + projectPath, section.id, - sectionAllWorkspaces + anchorEl + ); + }} + /> + {isSectionExpanded && ( +
+ {sectionDrafts.map((draft) => renderDraft(draft))} + {sectionWorkspaces.length > 0 ? ( + renderAgeTiers( + sectionWorkspaces, + getSectionTierKey(projectPath, section.id, 0).replace( + ":tier:0", + ":tier" + ), + section.id, + sectionAllWorkspaces + ) + ) : sectionDrafts.length === 0 ? ( +
+ No chats in this sub-project +
+ ) : null} +
+ )} + + ); + }; + + 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 ) - ) : 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 + )} + )} -
- ); - }; - - 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)} - - ); - })()} -
- )} -
- ); - }) + {/* Sections */} + {sections.map(renderSection)} + + ); + })()} +
+ )} +
+ ); + })} + )} diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 1c63e9f5fb2..d67c86661e4 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -13556,9 +13556,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 +13567,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(); @@ -13729,6 +13730,21 @@ describe("WorkspaceService reorderPinned across projects", () => { 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; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index dcb14826ce9..164f7a929e3 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -8139,9 +8139,12 @@ export class WorkspaceService extends EventEmitter { * 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, 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. + * 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 @@ -8187,21 +8190,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; } From b2155a9c3198fdc16239bfe1c2f4dd7096b844bd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:01:48 +0000 Subject: [PATCH 06/17] fix(sidebar): gate flat pinned-reorder block by the multi-project experiment The flat block in locatePinnedBlock included pins the sidebar hides while the multi-project experiment is off, so keyboard/palette moves could swap with an invisible row and appear to do nothing. The block now mirrors the sidebar's render gate (red-green tested); omitted hidden pins keep their slots server-side via the round-2 slot-preservation fix. --- src/browser/App.tsx | 3 ++ .../ProjectSidebar/ProjectSidebar.tsx | 6 ++- src/browser/utils/ui/pinnedReorder.test.ts | 37 ++++++++++++++++++- src/browser/utils/ui/pinnedReorder.ts | 21 +++++++++-- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index a624b6f8cbf..6fb376dd4c0 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -964,6 +964,8 @@ function AppInner() { sortedWorkspacesByProject, userProjects, readPersistedState(SIDEBAR_FLAT_MODE_KEY, false) + ? { multiProjectEnabled: multiProjectWorkspacesEnabled } + : false ); if (order) void reorderPinnedWorkspaces(order); }, @@ -972,6 +974,7 @@ function AppInner() { workspaceMetadata, sortedWorkspacesByProject, userProjects, + multiProjectWorkspacesEnabled, reorderPinnedWorkspaces, ] ); diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index a0386ebc71a..bd77704bb87 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -1987,7 +1987,7 @@ const ProjectSidebarInner: React.FC = ({ targetMeta, sortedWorkspacesByProject, userProjects, - flatSidebarEnabled + flatSidebarEnabled ? { multiProjectEnabled: multiProjectWorkspacesEnabled } : false ); if (!block) return; const order = computePinnedDropOrder(block, draggedId, targetId, edge); @@ -1998,6 +1998,7 @@ const ProjectSidebarInner: React.FC = ({ sortedWorkspacesByProject, userProjects, flatSidebarEnabled, + multiProjectWorkspacesEnabled, reorderPinnedWorkspaces, ] ); @@ -2018,7 +2019,7 @@ const ProjectSidebarInner: React.FC = ({ direction, sortedWorkspacesByProject, userProjects, - flatSidebarEnabled + flatSidebarEnabled ? { multiProjectEnabled: multiProjectWorkspacesEnabled } : false ); if (order) void reorderPinnedWorkspaces(order); return true; @@ -2028,6 +2029,7 @@ const ProjectSidebarInner: React.FC = ({ sortedWorkspacesByProject, userProjects, flatSidebarEnabled, + multiProjectWorkspacesEnabled, reorderPinnedWorkspaces, ] ); diff --git a/src/browser/utils/ui/pinnedReorder.test.ts b/src/browser/utils/ui/pinnedReorder.test.ts index ffe0a1fd40b..df4c012d1cb 100644 --- a/src/browser/utils/ui/pinnedReorder.test.ts +++ b/src/browser/utils/ui/pinnedReorder.test.ts @@ -153,12 +153,47 @@ describe("locatePinnedBlock", () => { ["/test/b", [b]], ]); - expect(locatePinnedBlock(a, sorted, new Map(), true)).toEqual({ + 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 45a548cb78a..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 @@ -58,12 +68,15 @@ export function locatePinnedBlock( meta: FrontendWorkspaceMetadata, sortedWorkspacesByProject: Map, userProjects: Map, - flatMode = false + flatMode: FlatPinnedBlockOptions | false = false ): PinnedBlock | null { if (!isWorkspacePinned(meta)) return null; - if (flatMode) { - const pinnedIds = collectFlatSectionRows(sortedWorkspacesByProject, () => true) + 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; @@ -141,7 +154,7 @@ export function computePinnedMoveOrderForWorkspace( direction: PinnedMoveDirection, sortedWorkspacesByProject: Map, userProjects: Map, - flatMode = false + flatMode: FlatPinnedBlockOptions | false = false ): string[] | null { const block = locatePinnedBlock(meta, sortedWorkspacesByProject, userProjects, flatMode); if (!block) return null; From d0dc30381f32b4db4c6c512424ba67d965c0bff2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:16:25 +0000 Subject: [PATCH 07/17] fix(sidebar): keep sub-project management reachable in flat mode Flat mode renders each project's sub-projects as compact SectionHeader rows under its management header: scoped new chat, rename, color, and delete stay reachable via mouse/touch. SectionHeader's expand toggle is now optional and renders a static folder icon when omitted, since nothing nests under the flat rows (red-green tested). --- .../ProjectSidebar/ProjectSidebar.test.tsx | 48 ++++++++++++ .../ProjectSidebar/ProjectSidebar.tsx | 76 +++++++++++++++++++ .../SectionHeader/SectionHeader.tsx | 53 +++++++------ 3 files changed, 154 insertions(+), 23 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 493cc339cf6..0aaa4806e63 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -971,6 +971,54 @@ describe("ProjectSidebar flat chat list", () => { expect(view.getAllByTestId(agentItemTestId("solo"))).toHaveLength(1); }); + test("keeps sub-project management reachable in flat mode", () => { + const workspace = { + ...createWorkspace("solo-sub", { title: "Solo chat" }), + projects: singleProjectRefs, + }; + projectContextValue = createProjectContextValue({ + userProjects: new Map([ + ["/projects/demo-project", { workspaces: [] }], + [ + "/projects/demo-project/features", + { + displayName: "Features", + parentProjectPath: "/projects/demo-project", + workspaces: [], + }, + ], + ]), + }); + updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true); + + const view = render( + undefined} + sortedWorkspacesByProject={new Map([["/projects/demo-project", [workspace]]])} + workspaceRecency={{ "solo-sub": Date.now() }} + /> + ); + + // SectionHeader is stubbed in this suite, so assert the management row's + // contract via its props: the flat row keeps the scoped controls but has + // no expansion toggle (nothing nests under it in flat mode). + expect(view.getByLabelText("Create workspace in demo-project")).toBeTruthy(); + const sectionHeaderCalls = ( + SectionHeaderModule.SectionHeader as unknown as { + mock: { + calls: Array<[Parameters[0]]>; + }; + } + ).mock.calls; + const sectionProps = sectionHeaderCalls + .map(([props]) => props) + .find((props) => props.section.id === "/projects/demo-project/features"); + expect(sectionProps?.section.name).toBe("Features"); + expect(sectionProps?.onToggleExpand).toBeUndefined(); + expect(sectionProps?.workspaceCount).toBe(0); + }); + test("coalesces best-of children into a task group in the flat list", () => { const parentWorkspace = { ...createWorkspace("parent", { title: "Parent workspace" }), diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index bd77704bb87..9b4ad1107b9 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -3092,6 +3092,82 @@ const ProjectSidebarInner: React.FC = ({ + {/* 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 shouldAutoEditSection = + autoEditingSection?.projectPath === projectPath && + autoEditingSection?.sectionId === subProjectPath; + return ( +
+ + 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 + ); + }} + /> +
+ ); + } + )} + {!flatSidebarEnabled && isExpanded && (
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 ? ( From 9977e7243b14a0ae2cfa82d517eb273cc1b65619 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:28:23 +0000 Subject: [PATCH 08/17] fix(sidebar): badge flat rows with their sub-project identity Chats and drafts scoped to a valid sub-project now badge with the sub-project's display name and color (stale references fall back to the parent project), since flat mode drops the section headers that used to convey that scope. Red-green tested. --- .../ProjectSidebar/ProjectSidebar.test.tsx | 47 +++++++++++++++++++ .../ProjectSidebar/ProjectSidebar.tsx | 32 +++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 0aaa4806e63..9836a7d21f1 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -1019,6 +1019,53 @@ describe("ProjectSidebar flat chat list", () => { expect(sectionProps?.workspaceCount).toBe(0); }); + test("badges flat rows with their sub-project identity, falling back when stale", () => { + const sectionScoped = { + ...createWorkspace("in-section", { title: "Scoped chat" }), + projects: singleProjectRefs, + subProjectPath: "/projects/demo-project/features", + }; + const staleScoped = { + ...createWorkspace("stale-section", { title: "Stale chat" }), + projects: singleProjectRefs, + subProjectPath: "/projects/demo-project/deleted", + }; + projectContextValue = createProjectContextValue({ + userProjects: new Map([ + ["/projects/demo-project", { displayName: "Demo", workspaces: [] }], + [ + "/projects/demo-project/features", + { + displayName: "Features", + parentProjectPath: "/projects/demo-project", + workspaces: [], + }, + ], + ]), + }); + updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true); + + const view = render( + undefined} + sortedWorkspacesByProject={ + new Map([["/projects/demo-project", [sectionScoped, staleScoped]]]) + } + workspaceRecency={{ "in-section": Date.now(), "stale-section": Date.now() }} + /> + ); + + // A valid sub-project reference badges with the sub-project's identity; + // a stale (deleted) reference falls back to the parent project badge. + expect( + within(view.getByTestId(agentItemTestId("in-section"))).getByText("Features") + ).toBeTruthy(); + expect( + within(view.getByTestId(agentItemTestId("stale-section"))).getByText("Demo") + ).toBeTruthy(); + }); + test("coalesces best-of children into a task group in the flat list", () => { const parentWorkspace = { ...createWorkspace("parent", { title: "Parent workspace" }), diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 9b4ad1107b9..7d703ee3784 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -1955,6 +1955,22 @@ const ProjectSidebarInner: React.FC = ({ color: resolveSectionColor(config?.color), }; }; + // Flat mode drops the section headers that used to convey sub-project + // scope, so rows scoped to a valid sub-project badge with its identity + // instead of the parent's. Stale references (deleted sections) fall back + // to the parent badge, mirroring getDraftSectionId's validation. + const resolveSubProjectBadge = ( + parentProjectPath: string, + subProjectPath: string | null | undefined + ): { name: string; color: string } | undefined => { + if (typeof subProjectPath !== "string") return undefined; + const config = userProjects.get(subProjectPath); + if (config?.parentProjectPath !== parentProjectPath) return undefined; + return { + name: getProjectDisplayName(subProjectPath, config), + color: resolveSectionColor(config.color), + }; + }; const getFlatProjectBadge = ( workspace: FrontendWorkspaceMetadata ): { name: string; color: string } | undefined => { @@ -1962,10 +1978,18 @@ const ProjectSidebarInner: React.FC = ({ if (isMultiProject(workspace)) { return { name: "Multi-project", color: resolveSectionColor(undefined) }; } - return getProjectBadge(workspace.projectPath); + return ( + resolveSubProjectBadge(workspace.projectPath, workspace.subProjectPath) ?? + getProjectBadge(workspace.projectPath) + ); }; - const getFlatDraftBadge = (projectPath: string): { name: string; color: string } | undefined => - projectPath === SCRATCH_PROJECT_CONFIG_KEY ? undefined : getProjectBadge(projectPath); + const getFlatDraftBadge = ( + projectPath: string, + subProjectPath: string | null | undefined + ): { name: string; color: string } | undefined => + projectPath === SCRATCH_PROJECT_CONFIG_KEY + ? undefined + : (resolveSubProjectBadge(projectPath, subProjectPath) ?? getProjectBadge(projectPath)); const handleReorder = useCallback( (draggedPath: string, targetPath: string) => { @@ -2552,7 +2576,7 @@ const ProjectSidebarInner: React.FC = ({ const isSelected = pendingNewWorkspaceProject === projectPath && pendingNewWorkspaceDraftId === draft.draftId; - const draftBadge = getFlatDraftBadge(projectPath); + const draftBadge = getFlatDraftBadge(projectPath, draft.subProjectPath); return ( Date: Thu, 27 Aug 2026 18:45:56 +0000 Subject: [PATCH 09/17] fix(sidebar): hierarchical sub-project badges and pinned block above flat drafts --- .../ProjectSidebar/ProjectSidebar.test.tsx | 67 ++++++++++++++++++- .../ProjectSidebar/ProjectSidebar.tsx | 37 ++++++++-- 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 9836a7d21f1..28f4506b5b0 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -1056,10 +1056,11 @@ describe("ProjectSidebar flat chat list", () => { /> ); - // A valid sub-project reference badges with the sub-project's identity; - // a stale (deleted) reference falls back to the parent project badge. + // A valid sub-project reference badges hierarchically (sub names are only + // unique within a parent); a stale (deleted) reference falls back to the + // parent project badge. expect( - within(view.getByTestId(agentItemTestId("in-section"))).getByText("Features") + within(view.getByTestId(agentItemTestId("in-section"))).getByText("Demo / Features") ).toBeTruthy(); expect( within(view.getByTestId(agentItemTestId("stale-section"))).getByText("Demo") @@ -1109,6 +1110,66 @@ describe("ProjectSidebar flat chat list", () => { expect(view.getByTestId(agentItemTestId("parent"))).toBeTruthy(); }); + test("keeps the pinned block above drafts in the flat list", () => { + const pinned = { + ...createWorkspace("pinned-chat", { title: "Pinned chat" }), + projects: singleProjectRefs, + pinnedAt: "2026-01-01T00:00:00.000Z", + }; + const regular = { + ...createWorkspace("regular-chat", { title: "Regular chat" }), + projects: singleProjectRefs, + }; + spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( + () => + ({ + selectedWorkspace: null, + setSelectedWorkspace: () => undefined, + preflightArchiveWorkspace: () => + Promise.resolve({ success: true, data: { kind: "ready" } }), + archiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "archived" } }), + removeWorkspace: () => Promise.resolve({ success: true }), + updateWorkspaceTitle: () => Promise.resolve({ success: true }), + refreshWorkspaceMetadata: () => Promise.resolve(), + pendingNewWorkspaceProject: null, + pendingNewWorkspaceDraftId: null, + workspaceDraftsByProject: { + "/projects/demo-project": [{ draftId: "draft-order", createdAt: Date.now() }], + }, + workspaceDraftPromotionsByProject: {}, + createWorkspaceDraft: () => undefined, + openWorkspaceDraft: () => undefined, + deleteWorkspaceDraft: () => undefined, + }) as unknown as ReturnType + ); + // Non-empty drafts render as rows; empty ones stay hidden. + updatePersistedState( + getInputKey(getDraftScopeId("/projects/demo-project", "draft-order")), + "Draft prompt" + ); + updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true); + + const view = render( + undefined} + sortedWorkspacesByProject={new Map([["/projects/demo-project", [pinned, regular]]])} + workspaceRecency={{ "pinned-chat": Date.now(), "regular-chat": Date.now() }} + /> + ); + + // Pinned chats keep the top of the flat list: a new draft slots in + // between the pinned block and the unpinned chats, never above pins. + const rows = Array.from( + view.container.querySelectorAll('[data-testid^="agent-item-"], [data-testid^="draft-item-"]') + ).map((row) => row.getAttribute("data-testid")); + expect(rows).toEqual([ + agentItemTestId("pinned-chat"), + "draft-item-draft-order", + agentItemTestId("regular-chat"), + ]); + }); + test("renders a promoted flat draft once as the live workspace row", () => { const promotedWorkspace = { ...createWorkspace("promoted", { title: "Promoted chat" }), diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 7d703ee3784..5f955257bc8 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -1815,6 +1815,25 @@ const ProjectSidebarInner: React.FC = ({ expandedCompletedParentIds, { isWorkspaceLiveActive } ); + // Pinned-at-the-top invariant vs drafts: pinned roots (their subtrees stay + // adjacent and never age out) render as their own segment above the draft + // rows, so adding a draft never displaces the user's pinned chats. + const flatPinnedRowIds = new Set(); + if (flatSidebarEnabled) { + let inPinnedSubtree = false; + for (const row of flatRowsForDisplay) { + if ((flatDepthByWorkspaceId[row.id] ?? 0) === 0) { + inPinnedSubtree = isWorkspacePinned(row); + } + if (inPinnedSubtree) flatPinnedRowIds.add(row.id); + } + } + const visiblePinnedFlatWorkspaces = visibleFlatWorkspaces.filter((row) => + flatPinnedRowIds.has(row.id) + ); + const visibleUnpinnedFlatWorkspaces = visibleFlatWorkspaces.filter( + (row) => !flatPinnedRowIds.has(row.id) + ); if (flatSidebarEnabled) { // Track runs that are (or were, this session) active so their groups stay // mounted across step gaps (mirrors the grouped per-project seeding). @@ -1956,9 +1975,11 @@ const ProjectSidebarInner: React.FC = ({ }; }; // Flat mode drops the section headers that used to convey sub-project - // scope, so rows scoped to a valid sub-project badge with its identity - // instead of the parent's. Stale references (deleted sections) fall back - // to the parent badge, mirroring getDraftSectionId's validation. + // scope, so rows scoped to a valid sub-project badge with a hierarchical + // "Parent / Sub" label: sub-project names are only unique within their + // parent, so the bare sub name (badge text and aria-label both derive from + // it) could collide across projects. Stale references (deleted sections) + // fall back to the parent badge, mirroring getDraftSectionId's validation. const resolveSubProjectBadge = ( parentProjectPath: string, subProjectPath: string | null | undefined @@ -1967,7 +1988,7 @@ const ProjectSidebarInner: React.FC = ({ const config = userProjects.get(subProjectPath); if (config?.parentProjectPath !== parentProjectPath) return undefined; return { - name: getProjectDisplayName(subProjectPath, config), + name: `${getProjectBadge(parentProjectPath).name} / ${getProjectDisplayName(subProjectPath, config)}`, color: resolveSectionColor(config.color), }; }; @@ -2563,6 +2584,12 @@ const ProjectSidebarInner: React.FC = ({ New chat + {renderCoalescedWorkspaceList(visiblePinnedFlatWorkspaces, flatRowsForDisplay, { + tierKeyPrefix: "flat", + depthByWorkspaceId: flatDepthByWorkspaceId, + baseRowMetaByWorkspaceId: flatRowMetaByWorkspaceId, + renderRow: renderFlatRow, + })} {flatDrafts.map(({ projectPath, draft }, index) => { const promotedMetadata = flatDraftPromotionsByDraftId.get(draft.draftId); if (promotedMetadata) { @@ -2607,7 +2634,7 @@ const ProjectSidebarInner: React.FC = ({ /> ); })} - {renderCoalescedWorkspaceList(visibleFlatWorkspaces, flatRowsForDisplay, { + {renderCoalescedWorkspaceList(visibleUnpinnedFlatWorkspaces, flatRowsForDisplay, { tierKeyPrefix: "flat", depthByWorkspaceId: flatDepthByWorkspaceId, baseRowMetaByWorkspaceId: flatRowMetaByWorkspaceId, From 66415fa9965e4afd01f3eed0e8ad053e103911dd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:29:46 +0000 Subject: [PATCH 10/17] fix(sidebar): expose full project badge label via shared tooltip --- .../AgentListItem/AgentListItem.test.tsx | 21 ++++++ .../AgentListItem/AgentListItem.tsx | 66 +++++++++++-------- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/src/browser/components/AgentListItem/AgentListItem.test.tsx b/src/browser/components/AgentListItem/AgentListItem.test.tsx index f75f0037361..c7593f331d1 100644 --- a/src/browser/components/AgentListItem/AgentListItem.test.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.test.tsx @@ -141,6 +141,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; }) => ( @@ -275,6 +283,7 @@ function renderWorkspaceItem( completedChildrenExpanded?: boolean; onToggleCompletedChildren?: (workspaceId: string) => void; onSelectWorkspace?: (selection: WorkspaceSelection) => void; + projectBadgeName?: string; } = {} ) { const metadata = options.metadata ?? createMetadata(); @@ -283,6 +292,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 +348,17 @@ 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("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 e0f85ed6cf5..1bff7edc2a4 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 { @@ -498,20 +498,24 @@ function DraftAgentListItemInner(props: DraftAgentListItemProps) { {draft.title} {props.projectBadgeName != null && ( - - {props.projectBadgeName} - + // The badge width cap can truncate hierarchical "Parent / Sub" + // names, so the shared tooltip keeps the full label reachable. + + + {props.projectBadgeName} + + )}
{hasPromptPreview && ( @@ -1349,20 +1353,24 @@ function RegularAgentListItemInner(props: AgentListItemProps) { {suppressGroupMemberTitle ? memberOnlyLabel : workspaceTitle} {props.projectBadgeName != null && ( - - {props.projectBadgeName} - + // The badge width cap can truncate hierarchical "Parent / Sub" + // names, so the shared tooltip keeps the full label reachable. + + + {props.projectBadgeName} + + )} {groupLabel && !suppressGroupMemberTitle && ( Date: Thu, 27 Aug 2026 19:53:26 +0000 Subject: [PATCH 11/17] fix(sidebar): flat sub-project drop targets, draft counts, and safe global pin timestamps --- .../ProjectSidebar/ProjectSidebar.test.tsx | 53 ++- .../ProjectSidebar/ProjectSidebar.tsx | 450 +++++++++--------- src/browser/contexts/WorkspaceContext.tsx | 17 +- src/common/utils/pin.test.ts | 46 +- src/common/utils/pin.ts | 42 +- src/node/services/workspaceService.test.ts | 16 + src/node/services/workspaceService.ts | 24 +- 7 files changed, 405 insertions(+), 243 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 28f4506b5b0..4ac76e3f12a 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -989,6 +989,34 @@ describe("ProjectSidebar flat chat list", () => { ], ]), }); + spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( + () => + ({ + selectedWorkspace: null, + setSelectedWorkspace: () => undefined, + preflightArchiveWorkspace: () => + Promise.resolve({ success: true, data: { kind: "ready" } }), + archiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "archived" } }), + removeWorkspace: () => Promise.resolve({ success: true }), + updateWorkspaceTitle: () => Promise.resolve({ success: true }), + refreshWorkspaceMetadata: () => Promise.resolve(), + pendingNewWorkspaceProject: null, + pendingNewWorkspaceDraftId: null, + workspaceDraftsByProject: { + "/projects/demo-project": [ + { + draftId: "draft-in-section", + createdAt: Date.now(), + subProjectPath: "/projects/demo-project/features", + }, + ], + }, + workspaceDraftPromotionsByProject: {}, + createWorkspaceDraft: () => undefined, + openWorkspaceDraft: () => undefined, + deleteWorkspaceDraft: () => undefined, + }) as unknown as ReturnType + ); updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true); const view = render( @@ -1002,7 +1030,8 @@ describe("ProjectSidebar flat chat list", () => { // SectionHeader is stubbed in this suite, so assert the management row's // contract via its props: the flat row keeps the scoped controls but has - // no expansion toggle (nothing nests under it in flat mode). + // no expansion toggle (nothing nests under it in flat mode), and its count + // includes section drafts like the grouped header. expect(view.getByLabelText("Create workspace in demo-project")).toBeTruthy(); const sectionHeaderCalls = ( SectionHeaderModule.SectionHeader as unknown as { @@ -1016,7 +1045,27 @@ describe("ProjectSidebar flat chat list", () => { .find((props) => props.section.id === "/projects/demo-project/features"); expect(sectionProps?.section.name).toBe("Features"); expect(sectionProps?.onToggleExpand).toBeUndefined(); - expect(sectionProps?.workspaceCount).toBe(0); + expect(sectionProps?.workspaceCount).toBe(1); + + // The management rows stay drop targets: the sub-project row assigns a + // dragged chat to the section, the project header clears the assignment. + const dropZoneCalls = ( + WorkspaceSectionDropZoneModule.WorkspaceSectionDropZone as unknown as { + mock: { + calls: Array< + [Parameters[0]] + >; + }; + } + ).mock.calls.map(([props]) => props); + const sectionZone = dropZoneCalls.find( + (props) => props.sectionId === "/projects/demo-project/features" + ); + expect(typeof sectionZone?.onDrop).toBe("function"); + const clearZone = dropZoneCalls.find( + (props) => props.sectionId === null && props.projectPath === "/projects/demo-project" + ); + expect(typeof clearZone?.onDrop).toBe("function"); }); test("badges flat rows with their sub-project identity, falling back when stale", () => { diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 5f955257bc8..c43a479181a 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -2938,210 +2938,240 @@ const ProjectSidebarInner: React.FC = ({ (workspace) => workspaceAttentionById.get(workspace.id) === true ); - return ( -
- { - if (projectContextMenu.suppressClickIfLongPress()) { - return; - } - if (isEditingProjectDisplayName) { - return; - } + // 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 = ( + { + if (projectContextMenu.suppressClickIfLongPress()) { + return; + } + if (isEditingProjectDisplayName) { + return; + } + handleAddWorkspace(projectPath); + }} + onContextMenu={(event) => handleOpenProjectMenu(event, projectPath)} + onTouchStart={(event) => + handleProjectContextMenuTouchStart(event, projectPath) + } + onTouchEnd={projectContextMenu.touchHandlers.onTouchEnd} + onTouchMove={projectContextMenu.touchHandlers.onTouchMove} + onKeyDown={(e: React.KeyboardEvent) => { + // Ignore key events from child buttons + if (e.target instanceof HTMLElement && e.target !== e.currentTarget) { + return; + } + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); handleAddWorkspace(projectPath); - }} - onContextMenu={(event) => handleOpenProjectMenu(event, projectPath)} - onTouchStart={(event) => - handleProjectContextMenuTouchStart(event, projectPath) } - onTouchEnd={projectContextMenu.touchHandlers.onTouchEnd} - onTouchMove={projectContextMenu.touchHandlers.onTouchMove} - onKeyDown={(e: React.KeyboardEvent) => { - // Ignore key events from child buttons - if (e.target instanceof HTMLElement && e.target !== e.currentTarget) { - return; - } - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleAddWorkspace(projectPath); - } - }} - role="button" - tabIndex={0} - aria-expanded={flatSidebarEnabled ? undefined : isExpanded} - aria-controls={flatSidebarEnabled ? undefined : workspaceListId} - aria-label={`Create workspace in ${projectName}`} - data-project-path={projectPath} - > - {flatSidebarEnabled ? ( - // Flat mode has no per-project chat nesting to - // expand, so the folder renders as a static icon. - - + {flatSidebarEnabled ? ( + // Flat mode has no per-project chat nesting to + // expand, so the folder renders as a static icon. + + + + ) : ( + + )} +
handleOpenProjectMenu(event, projectPath)} + > + + + {isEditingProjectDisplayName ? ( + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onContextMenu={(event) => event.stopPropagation()} + onChange={(event) => { + setEditingProjectDisplayName(event.target.value); + }} + onKeyDown={(event) => { + stopKeyboardPropagation(event); + if (event.key === "Escape") { + event.preventDefault(); + skipNextProjectNameBlurCommitRef.current = true; + cancelProjectDisplayNameEditing(); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + } + }} + onBlur={(event) => { + event.stopPropagation(); + if (skipNextProjectNameBlurCommitRef.current) { + skipNextProjectNameBlurCommitRef.current = false; + return; + } + void commitProjectDisplayNameEdit( + projectPath, + event.currentTarget.value + ); + }} + /> + ) : ( +
+ + {displayProjectName} + + + ({projectAgentCount}) + +
+ )} +
+ {projectPath} +
+
+ + - )} -
handleOpenProjectMenu(event, projectPath)} - > - - - {isEditingProjectDisplayName ? ( - event.stopPropagation()} - onMouseDown={(event) => event.stopPropagation()} - onContextMenu={(event) => event.stopPropagation()} - onChange={(event) => { - setEditingProjectDisplayName(event.target.value); - }} - onKeyDown={(event) => { - stopKeyboardPropagation(event); - if (event.key === "Escape") { - event.preventDefault(); - skipNextProjectNameBlurCommitRef.current = true; - cancelProjectDisplayNameEditing(); - return; - } + + + New chat ({formatKeybind(KEYBINDS.NEW_WORKSPACE)}) + + + + + + + Project options + + + ); - if (event.key === "Enter") { - event.preventDefault(); - event.currentTarget.blur(); - } - }} - onBlur={(event) => { - event.stopPropagation(); - if (skipNextProjectNameBlurCommitRef.current) { - skipNextProjectNameBlurCommitRef.current = false; - return; - } - void commitProjectDisplayNameEdit( - projectPath, - event.currentTarget.value - ); - }} - /> - ) : ( -
- - {displayProjectName} - - - ({projectAgentCount}) - -
- )} - - {projectPath} - -
- - - - - - New chat ({formatKeybind(KEYBINDS.NEW_WORKSPACE)}) - - - - - - - Project options - -
+ return ( +
+ {flatSidebarEnabled ? ( + // Dropping a chat on the project header clears its + // sub-project assignment, mirroring the grouped + // unsectioned drop zone. + + {projectHeaderRow} + + ) : ( + projectHeaderRow + )} {/* Flat mode keeps sub-project management reachable: the same SectionHeader controls (scoped new chat, @@ -3153,11 +3183,23 @@ const ProjectSidebarInner: React.FC = ({ const sectionRows = topLevelProjectWorkspaces.filter( (workspace) => workspace.subProjectPath === subProjectPath ); + // 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 ( -
+ = ({ color: subProjectConfig.color, }} isExpanded={false} - workspaceCount={sectionRows.length} + workspaceCount={sectionRows.length + sectionDraftCount} hasAttention={sectionRows.some( (workspace) => workspaceAttentionById.get(workspace.id) === true @@ -3214,7 +3256,7 @@ const ProjectSidebarInner: React.FC = ({ ); }} /> -
+ ); } )} @@ -3497,24 +3539,6 @@ const ProjectSidebarInner: React.FC = ({ 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) ?? []; diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index e4f1307c9c7..343723a2ed2 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 { nextMonotonicPinnedAtIso, reassignPinnedTimestamps } from "@/common/utils/pin"; import { shouldApplyWorkspaceAiSettingsFromBackend } from "@/browser/utils/workspaceAiSettingsSync"; import { isAbortError } from "@/browser/utils/isAbortError"; import { findAdjacentWorkspaceId } from "@/browser/utils/ui/workspaceDomNav"; @@ -1516,17 +1516,14 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { 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. + // Mirror the server's global append-only ordering (all projects, not + // just this one) so the flat sidebar's unified pinned block shows the + // row where the 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; - } - optimisticPinnedAt = new Date(pinnedAtMs).toISOString(); + optimisticPinnedAt = nextMonotonicPinnedAtIso( + Array.from(prev.values(), (other) => other.pinnedAt) + ); } const next = new Map(prev); next.set(workspaceId, { ...meta, pinnedAt: optimisticPinnedAt }); diff --git a/src/common/utils/pin.test.ts b/src/common/utils/pin.test.ts index 998c590c761..1d143155484 100644 --- a/src/common/utils/pin.test.ts +++ b/src/common/utils/pin.test.ts @@ -1,6 +1,37 @@ import { describe, expect, it } from "bun:test"; -import { comparePinnedOrder, reassignPinnedTimestamps, recomposePinnedOrder } from "./pin"; +import { + 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("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("comparePinnedOrder", () => { it("sorts by pinnedAt ascending with id tie-break", () => { @@ -22,6 +53,19 @@ describe("comparePinnedOrder", () => { }); describe("reassignPinnedTimestamps", () => { + 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..a5ae13f39cb 100644 --- a/src/common/utils/pin.ts +++ b/src/common/utils/pin.ts @@ -40,6 +40,44 @@ function parsePinnedAtMs(pinnedAt: string | undefined): number { return Number.isFinite(ms) ? ms : 0; } +/** + * Highest epoch ms a JS Date can represent. 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. Successor scans treat + * such values as absent instead (self-healing). + */ +const MAX_DATE_MS = 8_640_000_000_000_000; + +/** Epoch ms of a pinnedAt whose +1ms successor is representable, else null. */ +function pinnedAtMsForSuccessorScan(pinnedAt: string | undefined): number | null { + if (!pinnedAt) return null; + const ms = new Date(pinnedAt).getTime(); + return Number.isFinite(ms) && ms + 1 <= MAX_DATE_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 = nowMs; + for (const value of existingPinnedAts) { + const ms = pinnedAtMsForSuccessorScan(value); + if (ms !== null && ms >= pinnedAtMs) pinnedAtMs = ms + 1; + } + return new Date(pinnedAtMs).toISOString(); +} + /** * Stable pinned-block comparator: pinnedAt ascending (new pins append at the * bottom of the pinned block), workspace id as deterministic tie-breaker. @@ -75,8 +113,10 @@ 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))) + .map((id) => pinnedAtMsForSuccessorScan(currentPinnedAtById.get(id)) ?? 0) .sort((a, b) => a - b); const changed = new Map(); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index d67c86661e4..25f9d5a9568 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -13338,6 +13338,22 @@ 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("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); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 164f7a929e3..579bf6aab9c 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, + nextMonotonicPinnedAtIso, reassignPinnedTimestamps, } from "@/common/utils/pin"; import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; @@ -8093,22 +8094,13 @@ export class WorkspaceService extends EventEmitter { if (workspaceEntry.pinnedAt) { return config; } - // Server-generated monotonic timestamp: strictly greater than every existing - // pin across all projects so rapid pins always append deterministically, even - // if the wall clock is skewed or several pins land within the same millisecond. - // The global scan (not just this bucket) keeps the flat sidebar's unified - // pinned block appending at the bottom too. - let pinnedAtMs = Date.now(); - for (const project of config.projects.values()) { - for (const entry of project.workspaces) { - if (!entry.pinnedAt) continue; - const existingMs = new Date(entry.pinnedAt).getTime(); - if (Number.isFinite(existingMs) && existingMs >= pinnedAtMs) { - pinnedAtMs = existingMs + 1; - } - } - } - workspaceEntry.pinnedAt = new Date(pinnedAtMs).toISOString(); + // Server-generated global monotonic timestamp; see nextMonotonicPinnedAtIso + // for the ordering and corrupted-timestamp rationale. + workspaceEntry.pinnedAt = nextMonotonicPinnedAtIso( + Array.from(config.projects.values()).flatMap((project) => + project.workspaces.map((entry) => entry.pinnedAt) + ) + ); updated = true; } else if (workspaceEntry.pinnedAt) { delete workspaceEntry.pinnedAt; From 2d603e969b9ec81a32e2c3905a068d4616bd2705 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:05:26 +0000 Subject: [PATCH 12/17] fix(pin): sort corrupted boundary pin timestamps consistently with the successor scan --- src/common/utils/pin.test.ts | 13 +++++++++++++ src/common/utils/pin.ts | 13 +++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/common/utils/pin.test.ts b/src/common/utils/pin.test.ts index 1d143155484..89f639152a9 100644 --- a/src/common/utils/pin.test.ts +++ b/src/common/utils/pin.test.ts @@ -34,6 +34,19 @@ describe("nextMonotonicPinnedAtIso", () => { }); 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" }, diff --git a/src/common/utils/pin.ts b/src/common/utils/pin.ts index a5ae13f39cb..fe8ab37ab31 100644 --- a/src/common/utils/pin.ts +++ b/src/common/utils/pin.ts @@ -34,10 +34,15 @@ 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; } /** @@ -116,7 +121,7 @@ export function reassignPinnedTimestamps( // 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) => pinnedAtMsForSuccessorScan(currentPinnedAtById.get(id)) ?? 0) + .map((id) => parsePinnedAtMs(currentPinnedAtById.get(id))) .sort((a, b) => a - b); const changed = new Map(); From c220bde067ab7ca8c5e743b5a57f2fdad633ac22 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:20:38 +0000 Subject: [PATCH 13/17] fix(sidebar): clamp pin timestamps to sane bounds, notify flat-mode story reset, carry drag section identity --- .../AgentListItem/AgentListItem.test.tsx | 18 +++++++++++- .../AgentListItem/AgentListItem.tsx | 6 +++- src/browser/stories/meta.tsx | 7 +++-- src/common/utils/pin.test.ts | 16 ++++++++++ src/common/utils/pin.ts | 29 +++++++++++-------- 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/browser/components/AgentListItem/AgentListItem.test.tsx b/src/browser/components/AgentListItem/AgentListItem.test.tsx index c7593f331d1..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; @@ -180,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, })); @@ -359,6 +363,18 @@ describe("AgentListItem", () => { 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 1bff7edc2a4..90301787d6b 100644 --- a/src/browser/components/AgentListItem/AgentListItem.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.tsx @@ -905,7 +905,10 @@ function RegularAgentListItemInner(props: AgentListItemProps) { type: WORKSPACE_DRAG_TYPE, workspaceId, projectPath, - currentSectionId: sectionId, + // Flat rows render without the sectionId prop (no section indent), so + // fall back to the row's own sub-project scope; drop zones use this to + // treat same-section drops as no-ops. + currentSectionId: sectionId ?? metadata.subProjectPath, pinned: isPinned, pinnedReorderGroup: props.pinnedReorderGroup, // Extra fields for custom drag layer preview @@ -921,6 +924,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) { workspaceId, projectPath, sectionId, + metadata.subProjectPath, isDisabled, isPinned, props.pinnedReorderGroup, diff --git a/src/browser/stories/meta.tsx b/src/browser/stories/meta.tsx index d9724cf13a4..fc94ab92460 100644 --- a/src/browser/stories/meta.tsx +++ b/src/browser/stories/meta.tsx @@ -92,9 +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 so later - // stories keep their project folders. - localStorage.removeItem(SIDEBAR_FLAT_MODE_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/common/utils/pin.test.ts b/src/common/utils/pin.test.ts index 89f639152a9..82c19149613 100644 --- a/src/common/utils/pin.test.ts +++ b/src/common/utils/pin.test.ts @@ -24,6 +24,22 @@ describe("nextMonotonicPinnedAtIso", () => { 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"], diff --git a/src/common/utils/pin.ts b/src/common/utils/pin.ts index fe8ab37ab31..251648bcbd7 100644 --- a/src/common/utils/pin.ts +++ b/src/common/utils/pin.ts @@ -46,20 +46,23 @@ function parsePinnedAtMs(pinnedAt: string | undefined): number { } /** - * Highest epoch ms a JS Date can represent. 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. Successor scans treat - * such values as absent instead (self-healing). + * 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_DATE_MS = 8_640_000_000_000_000; +const MAX_PINNED_AT_MS = 8_640_000_000_000_000 - 1; -/** Epoch ms of a pinnedAt whose +1ms successor is representable, else null. */ +/** 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 + 1 <= MAX_DATE_MS ? ms : null; + return Number.isFinite(ms) && ms <= MAX_PINNED_AT_MS ? ms : null; } /** @@ -75,10 +78,10 @@ export function nextMonotonicPinnedAtIso( existingPinnedAts: Iterable, nowMs: number = Date.now() ): string { - let pinnedAtMs = nowMs; + let pinnedAtMs = Math.min(nowMs, MAX_PINNED_AT_MS); for (const value of existingPinnedAts) { const ms = pinnedAtMsForSuccessorScan(value); - if (ms !== null && ms >= pinnedAtMs) pinnedAtMs = ms + 1; + if (ms !== null && ms >= pinnedAtMs) pinnedAtMs = Math.min(ms + 1, MAX_PINNED_AT_MS); } return new Date(pinnedAtMs).toISOString(); } @@ -127,7 +130,9 @@ export function reassignPinnedTimestamps( const changed = new Map(); let previousMs = Number.NEGATIVE_INFINITY; orderedIds.forEach((id, index) => { - const ms = Math.max(poolMs[index], previousMs + 1); + // Clamped like generation: +1ms nudges near the sane maximum must not + // escape the accepted domain (ties there fall to the id tie-break). + const ms = Math.min(Math.max(poolMs[index], previousMs + 1), MAX_PINNED_AT_MS); previousMs = ms; const iso = new Date(ms).toISOString(); if (currentPinnedAtById.get(id) !== iso) { From 6ba6d29e654981dcd42f62ac2605d750f75fc37a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:34:48 +0000 Subject: [PATCH 14/17] fix(pin): heal saturated pin timestamps on write so ordering keys stay unique --- src/browser/contexts/WorkspaceContext.tsx | 39 ++++++++++++-------- src/common/utils/pin.test.ts | 31 ++++++++++++++++ src/common/utils/pin.ts | 42 ++++++++++++++++++++++ src/node/services/workspaceService.test.ts | 22 ++++++++++++ src/node/services/workspaceService.ts | 33 +++++++++++++---- 5 files changed, 146 insertions(+), 21 deletions(-) diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 343723a2ed2..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 { nextMonotonicPinnedAtIso, 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,33 +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; + 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) so the flat sidebar's unified pinned block shows the - // row where the authoritative metadata will keep it. + // 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) { - optimisticPinnedAt = nextMonotonicPinnedAtIso( - Array.from(prev.values(), (other) => other.pinnedAt) + 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 = 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/common/utils/pin.test.ts b/src/common/utils/pin.test.ts index 82c19149613..fba57a04ee3 100644 --- a/src/common/utils/pin.test.ts +++ b/src/common/utils/pin.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { + appendPinnedTimestamp, comparePinnedOrder, nextMonotonicPinnedAtIso, reassignPinnedTimestamps, @@ -49,6 +50,36 @@ describe("nextMonotonicPinnedAtIso", () => { }); }); +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("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 = [ diff --git a/src/common/utils/pin.ts b/src/common/utils/pin.ts index 251648bcbd7..d3f04095590 100644 --- a/src/common/utils/pin.ts +++ b/src/common/utils/pin.ts @@ -86,6 +86,48 @@ export function nextMonotonicPinnedAtIso( 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 + ); + if (!pinned.some((entry) => entry.pinnedAt === pinnedAt)) { + 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 } + ); +} + /** * Stable pinned-block comparator: pinnedAt ascending (new pins append at the * bottom of the pinned block), workspace id as deterministic tie-breaker. diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 25f9d5a9568..e2b10dc36b3 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -13354,6 +13354,28 @@ describe("WorkspaceService setPinned", () => { 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); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 579bf6aab9c..b31e037254d 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -14,7 +14,7 @@ import { isWorkspaceArchived } from "@/common/utils/archive"; import { comparePinnedOrder, isWorkspacePinned, - nextMonotonicPinnedAtIso, + appendPinnedTimestamp, reassignPinnedTimestamps, } from "@/common/utils/pin"; import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; @@ -8063,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); @@ -8094,13 +8095,28 @@ export class WorkspaceService extends EventEmitter { if (workspaceEntry.pinnedAt) { return config; } - // Server-generated global monotonic timestamp; see nextMonotonicPinnedAtIso - // for the ordering and corrupted-timestamp rationale. - workspaceEntry.pinnedAt = nextMonotonicPinnedAtIso( - Array.from(config.projects.values()).flatMap((project) => - project.workspaces.map((entry) => entry.pinnedAt) - ) + // 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 = pinnedAt; updated = true; } else if (workspaceEntry.pinnedAt) { delete workspaceEntry.pinnedAt; @@ -8114,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); } From ce39dd80e96fb0e82daed265924b8288008c3de9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:46:43 +0000 Subject: [PATCH 15/17] fix(sidebar): gate _multi drafts behind the multi-project experiment in flat mode --- .../ProjectSidebar/ProjectSidebar.test.tsx | 52 +++++++++++++++++++ .../ProjectSidebar/ProjectSidebar.tsx | 11 +++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 4ac76e3f12a..2eb795a23b9 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -943,6 +943,58 @@ describe("ProjectSidebar flat chat list", () => { expect(view.queryByTestId(agentItemTestId("multi"))).toBeNull(); }); + test("filters _multi drafts out of the flat list while the experiment is disabled", () => { + spyOn(ExperimentsModule, "useExperimentValue").mockImplementation(() => false); + const workspace = { + ...createWorkspace("solo-draft-gate", { title: "Solo chat" }), + projects: singleProjectRefs, + }; + spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( + () => + ({ + selectedWorkspace: null, + setSelectedWorkspace: () => undefined, + preflightArchiveWorkspace: () => + Promise.resolve({ success: true, data: { kind: "ready" } }), + archiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "archived" } }), + removeWorkspace: () => Promise.resolve({ success: true }), + updateWorkspaceTitle: () => Promise.resolve({ success: true }), + refreshWorkspaceMetadata: () => Promise.resolve(), + pendingNewWorkspaceProject: null, + pendingNewWorkspaceDraftId: null, + workspaceDraftsByProject: { + _multi: [{ draftId: "draft-multi", createdAt: Date.now() }], + "/projects/demo-project": [{ draftId: "draft-single", createdAt: Date.now() }], + }, + workspaceDraftPromotionsByProject: {}, + createWorkspaceDraft: () => undefined, + openWorkspaceDraft: () => undefined, + deleteWorkspaceDraft: () => undefined, + }) as unknown as ReturnType + ); + // Give both drafts persisted content so their rows would render. + updatePersistedState(getInputKey(getDraftScopeId("_multi", "draft-multi")), "Multi draft"); + updatePersistedState( + getInputKey(getDraftScopeId("/projects/demo-project", "draft-single")), + "Single draft" + ); + updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true); + + const view = render( + undefined} + sortedWorkspacesByProject={new Map([["/projects/demo-project", [workspace]]])} + workspaceRecency={{ "solo-draft-gate": Date.now() }} + /> + ); + + // The _multi draft follows the metadata gate: hidden while the experiment + // is off, while ordinary project drafts still render. + expect(view.getByTestId("draft-item-draft-single")).toBeTruthy(); + expect(view.queryByTestId("draft-item-draft-multi")).toBeNull(); + }); + test("keeps project management headers reachable in flat mode without nesting chats", () => { const workspace = { ...createWorkspace("solo", { title: "Solo chat" }), diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index c43a479181a..f31ca091582 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -140,7 +140,10 @@ import { getErrorMessage } from "@/common/utils/errors"; import { isMultiProject } from "@/common/utils/multiProject"; import { isWorkspacePinnable, isWorkspacePinned } from "@/common/utils/pin"; import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_SIDEBAR_SECTION_ID } from "@/common/constants/scratch"; -import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProject"; +import { + MULTI_PROJECT_CONFIG_KEY, + MULTI_PROJECT_SIDEBAR_SECTION_ID, +} from "@/common/constants/multiProject"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { HexColorPicker } from "react-colorful"; @@ -1779,9 +1782,14 @@ const ProjectSidebarInner: React.FC = ({ // Draft-to-workspace promotions: like grouped mode, the just-created // workspace renders in its draft's position, so it must be suppressed from // the normal rows or the same chat appears twice during creation. + // Drafts follow the same experiment gate as flatWorkspaces: _multi drafts + // (and their promotions) stay hidden in flat mode while the experiment is off. + const isGatedFlatDraftBucket = (projectPath: string): boolean => + !multiProjectWorkspacesEnabled && projectPath === MULTI_PROJECT_CONFIG_KEY; const flatDraftPromotionsByDraftId = new Map(); if (flatSidebarEnabled) { for (const [projectPath, drafts] of Object.entries(workspaceDraftsByProject)) { + if (isGatedFlatDraftBucket(projectPath)) continue; const promotions = workspaceDraftPromotionsByProject[projectPath] ?? {}; for (const draft of drafts) { const promoted = promotions[draft.draftId]; @@ -1959,6 +1967,7 @@ const ProjectSidebarInner: React.FC = ({ ); const flatDrafts = Object.entries(workspaceDraftsByProject) + .filter(([projectPath]) => !isGatedFlatDraftBucket(projectPath)) .flatMap(([projectPath, drafts]) => drafts.map((draft) => ({ projectPath, draft }))) .sort((a, b) => b.draft.createdAt - a.draft.createdAt); // Project headers render in both modes: grouped mode nests each project's From 7f104268ab1a066c3c2ce8725f054b01252db274 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:02:45 +0000 Subject: [PATCH 16/17] fix(sidebar): compact capped pin-key ties on reorder and label _multi draft badges --- .../ProjectSidebar/ProjectSidebar.test.tsx | 50 +++++++++++++++++++ .../ProjectSidebar/ProjectSidebar.tsx | 9 +++- src/common/utils/pin.test.ts | 16 ++++++ src/common/utils/pin.ts | 22 +++++--- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 2eb795a23b9..a7c6b63296b 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -316,6 +316,7 @@ function installProjectSidebarTestDoubles() { return (
{props.draft.title ?? "Draft"} + {props.projectBadgeName != null && {props.projectBadgeName}}
); } @@ -995,6 +996,55 @@ describe("ProjectSidebar flat chat list", () => { expect(view.queryByTestId("draft-item-draft-multi")).toBeNull(); }); + test("labels _multi drafts with the multi-project badge instead of the internal key", () => { + const workspace = { + ...createWorkspace("solo-multi-badge", { title: "Solo chat" }), + projects: singleProjectRefs, + }; + spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( + () => + ({ + selectedWorkspace: null, + setSelectedWorkspace: () => undefined, + preflightArchiveWorkspace: () => + Promise.resolve({ success: true, data: { kind: "ready" } }), + archiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "archived" } }), + removeWorkspace: () => Promise.resolve({ success: true }), + updateWorkspaceTitle: () => Promise.resolve({ success: true }), + refreshWorkspaceMetadata: () => Promise.resolve(), + pendingNewWorkspaceProject: null, + pendingNewWorkspaceDraftId: null, + workspaceDraftsByProject: { + _multi: [{ draftId: "draft-multi-badge", createdAt: Date.now() }], + }, + workspaceDraftPromotionsByProject: {}, + createWorkspaceDraft: () => undefined, + openWorkspaceDraft: () => undefined, + deleteWorkspaceDraft: () => undefined, + }) as unknown as ReturnType + ); + updatePersistedState( + getInputKey(getDraftScopeId("_multi", "draft-multi-badge")), + "Multi draft" + ); + updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true); + + const view = render( + undefined} + sortedWorkspacesByProject={new Map([["/projects/demo-project", [workspace]]])} + workspaceRecency={{ "solo-multi-badge": Date.now() }} + /> + ); + + // The _multi bucket is a system key excluded from userProjects; the badge + // must show the explicit multi-project label, never the raw key. + const row = view.getByTestId("draft-item-draft-multi-badge"); + expect(within(row).getByText("Multi-project")).toBeTruthy(); + expect(within(row).queryByText(/_multi/)).toBeNull(); + }); + test("keeps project management headers reachable in flat mode without nesting chats", () => { const workspace = { ...createWorkspace("solo", { title: "Solo chat" }), diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index f31ca091582..92b1416f113 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -2001,12 +2001,15 @@ const ProjectSidebarInner: React.FC = ({ color: resolveSectionColor(config.color), }; }; + // The _multi bucket is a system key excluded from userProjects, so both + // chats and drafts label it explicitly instead of leaking the internal key. + const multiProjectBadge = { name: "Multi-project", color: resolveSectionColor(undefined) }; const getFlatProjectBadge = ( workspace: FrontendWorkspaceMetadata ): { name: string; color: string } | undefined => { if (workspace.parentWorkspaceId != null || workspace.kind === "scratch") return undefined; if (isMultiProject(workspace)) { - return { name: "Multi-project", color: resolveSectionColor(undefined) }; + return multiProjectBadge; } return ( resolveSubProjectBadge(workspace.projectPath, workspace.subProjectPath) ?? @@ -2019,7 +2022,9 @@ const ProjectSidebarInner: React.FC = ({ ): { name: string; color: string } | undefined => projectPath === SCRATCH_PROJECT_CONFIG_KEY ? undefined - : (resolveSubProjectBadge(projectPath, subProjectPath) ?? getProjectBadge(projectPath)); + : projectPath === MULTI_PROJECT_CONFIG_KEY + ? multiProjectBadge + : (resolveSubProjectBadge(projectPath, subProjectPath) ?? getProjectBadge(projectPath)); const handleReorder = useCallback( (draggedPath: string, targetPath: string) => { diff --git a/src/common/utils/pin.test.ts b/src/common/utils/pin.test.ts index fba57a04ee3..bf180c43278 100644 --- a/src/common/utils/pin.test.ts +++ b/src/common/utils/pin.test.ts @@ -113,6 +113,22 @@ 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( diff --git a/src/common/utils/pin.ts b/src/common/utils/pin.ts index d3f04095590..4ab05391aaf 100644 --- a/src/common/utils/pin.ts +++ b/src/common/utils/pin.ts @@ -169,14 +169,24 @@ export function reassignPinnedTimestamps( .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) => { - // Clamped like generation: +1ms nudges near the sane maximum must not - // escape the accepted domain (ties there fall to the id tie-break). - const ms = Math.min(Math.max(poolMs[index], previousMs + 1), MAX_PINNED_AT_MS); - previousMs = ms; - const iso = new Date(ms).toISOString(); + const iso = new Date(assignedMs[index]).toISOString(); if (currentPinnedAtById.get(id) !== iso) { changed.set(id, iso); } From cdb95ca2ee42a42fa8e11882bb10565820cdb9d9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:14:34 +0000 Subject: [PATCH 17/17] fix(pin): detect capped pin-key collisions by parsed value --- src/common/utils/pin.test.ts | 14 ++++++++++++++ src/common/utils/pin.ts | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/common/utils/pin.test.ts b/src/common/utils/pin.test.ts index bf180c43278..5ccf866d81d 100644 --- a/src/common/utils/pin.test.ts +++ b/src/common/utils/pin.test.ts @@ -60,6 +60,20 @@ describe("appendPinnedTimestamp", () => { 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"); diff --git a/src/common/utils/pin.ts b/src/common/utils/pin.ts index 4ab05391aaf..cb08f2f594d 100644 --- a/src/common/utils/pin.ts +++ b/src/common/utils/pin.ts @@ -103,7 +103,11 @@ export function appendPinnedTimestamp( pinned.map((entry) => entry.pinnedAt), nowMs ); - if (!pinned.some((entry) => entry.pinnedAt === pinnedAt)) { + // 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);